diff --git a/claude_bottle/backend/docker/capability_apply.py b/claude_bottle/backend/docker/capability_apply.py index 1a8b96a..6b69dcd 100644 --- a/claude_bottle/backend/docker/capability_apply.py +++ b/claude_bottle/backend/docker/capability_apply.py @@ -43,6 +43,7 @@ from .bottle_state import ( transcript_snapshot_dir, write_per_bottle_dockerfile, ) +from .sidecar_bundle import sidecar_bundle_container_name # 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_WORKSPACE_IN_CONTAINER = f"{_AGENT_HOME_IN_CONTAINER}/workspace" -# 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. +# Per-bottle resource name patterns (mirroring prepare.py). def _agent_container_name(slug: str) -> str: 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.""" return [ _agent_container_name(slug), - f"claude-bottle-cred-proxy-{slug}", - f"claude-bottle-pipelock-{slug}", - f"claude-bottle-git-gate-{slug}", - f"claude-bottle-supervise-{slug}", + sidecar_bundle_container_name(slug), ] diff --git a/claude_bottle/backend/docker/compose.py b/claude_bottle/backend/docker/compose.py index ccec1ac..e09614c 100644 --- a/claude_bottle/backend/docker/compose.py +++ b/claude_bottle/backend/docker/compose.py @@ -49,8 +49,9 @@ from ...egress import ( EGRESS_HOSTNAME, EGRESS_ROUTES_IN_CONTAINER, ) +from ...git_gate import GIT_GATE_HOSTNAME, git_gate_aggregate_extra_hosts from ...log import die, warn -from ...git_gate import git_gate_aggregate_extra_hosts +from ...pipelock import PIPELOCK_HOSTNAME from ...supervise import ( CURRENT_CONFIG_DIR_IN_AGENT, QUEUE_DIR_IN_CONTAINER, @@ -62,20 +63,17 @@ from .bottle_plan import DockerBottlePlan from .egress import ( EGRESS_CA_IN_CONTAINER, EGRESS_PIPELOCK_CA_IN_CONTAINER, - egress_container_name, ) from .git_gate import ( GIT_GATE_ACCESS_HOOK_IN_CONTAINER, GIT_GATE_CREDS_DIR_IN_CONTAINER, GIT_GATE_ENTRYPOINT_IN_CONTAINER, GIT_GATE_HOOK_IN_CONTAINER, - git_gate_container_name, ) from .pipelock import ( PIPELOCK_CA_CERT_IN_CONTAINER, PIPELOCK_CA_KEY_IN_CONTAINER, PIPELOCK_PORT, - pipelock_container_name, ) from .provision.ca import AGENT_CA_BUNDLE, AGENT_CA_PATH from .sidecar_bundle import ( @@ -83,7 +81,6 @@ from .sidecar_bundle import ( SIDECAR_BUNDLE_IMAGE, sidecar_bundle_container_name, ) -from .supervise import supervise_container_name # 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, }) - # Internal-network aliases: every shortname + long-form legacy - # name routes to the bundle so the agent's HTTPS_PROXY URL - # (which references either `pipelock` or `egress`) keeps - # resolving without an agent-side change. + # Internal-network aliases: the agent reaches each daemon through + # its short name (pipelock / egress / git-gate / supervise) which + # the bundle answers as if it were the daemon itself. internal_aliases = [ - pipelock_container_name(plan.slug), + PIPELOCK_HOSTNAME, EGRESS_HOSTNAME, - egress_container_name(plan.slug), ] if gp.upstreams: - internal_aliases.append(git_gate_container_name(plan.slug)) + internal_aliases.append(GIT_GATE_HOSTNAME) if sp is not None: internal_aliases.append(SUPERVISE_HOSTNAME) - internal_aliases.append(supervise_container_name(plan.slug)) service: dict[str, Any] = { "image": SIDECAR_BUNDLE_IMAGE, @@ -330,7 +324,7 @@ def _agent_proxy_url(plan: DockerBottlePlan) -> str: if plan.egress_plan.routes: from .egress import 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: diff --git a/claude_bottle/backend/docker/egress.py b/claude_bottle/backend/docker/egress.py index 1844ed9..beeb47c 100644 --- a/claude_bottle/backend/docker/egress.py +++ b/claude_bottle/backend/docker/egress.py @@ -1,11 +1,11 @@ -"""DockerEgress — the Docker-specific lifecycle for the -per-bottle egress sidecar (PRD 0017). Inherits the platform- -agnostic prepare step (route lift + routes.yaml render + token-env -map derivation) from `Egress`. +"""Docker-side egress helpers: port pin, in-container CA paths, +container naming, and the host-side mitmproxy CA mint. The +prepare-time routes-yaml rendering itself lives on the +platform-neutral `Egress` ABC — backends instantiate it directly. -Chunks 1+2 of the PRD: the lifecycle is implemented and wired into -launch.py — cred-proxy is gone. Chunk 3 retargets the cred-proxy- -block remediation flow (PRD 0014).""" +The per-container `.start()` / `.stop()` lifecycle was removed in +PRD 0024 chunk 3; the sidecar bundle (PRD 0024) runs egress +under its python init supervisor.""" from __future__ import annotations @@ -13,7 +13,6 @@ import os import subprocess from pathlib import Path -from ...egress import Egress from ...log import die @@ -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-` - resolves to the bundle's IP.""" - return f"claude-bottle-egress-{slug}" - - def egress_tls_init(stage_dir: Path) -> tuple[Path, Path]: """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.chmod(0o644) return (mitm, cert_path) - - -class DockerEgress(Egress): - """Docker-flavored Egress: inherits `.prepare()` from the base. - Container lifecycle is owned by compose; per-container - `.start()` / `.stop()` were removed in PRD 0024 chunk 3.""" diff --git a/claude_bottle/backend/docker/git_gate.py b/claude_bottle/backend/docker/git_gate.py index 58bf8fa..19e397b 100644 --- a/claude_bottle/backend/docker/git_gate.py +++ b/claude_bottle/backend/docker/git_gate.py @@ -1,13 +1,11 @@ -"""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 constants: in-container paths the renderer's +bind-mounts target + the listening port. 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" @@ -16,24 +14,3 @@ GIT_GATE_CREDS_DIR_IN_CONTAINER = "/git-gate/creds" # git daemon's default listening port. 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-` - 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).""" diff --git a/claude_bottle/backend/docker/launch.py b/claude_bottle/backend/docker/launch.py index 5b48711..14b3442 100644 --- a/claude_bottle/backend/docker/launch.py +++ b/claude_bottle/backend/docker/launch.py @@ -65,7 +65,7 @@ from .compose import ( ) from .egress import egress_tls_init from .pipelock import ( - pipelock_proxy_url, + BUNDLE_LOCAL_PIPELOCK_URL, pipelock_tls_init, ) @@ -149,7 +149,7 @@ def launch( mitmproxy_ca_host_path=egress_ca_host, mitmproxy_ca_cert_only_host_path=egress_ca_cert_only, 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 if supervise_plan is not None: diff --git a/claude_bottle/backend/docker/pipelock.py b/claude_bottle/backend/docker/pipelock.py index 3bdd96e..c0a9821 100644 --- a/claude_bottle/backend/docker/pipelock.py +++ b/claude_bottle/backend/docker/pipelock.py @@ -1,14 +1,12 @@ -"""DockerPipelockProxy — the Docker-specific implementation of the -sidecar's `.prepare()` step + in-container CA path constants. -Inherits the platform-agnostic YAML-config generation from -PipelockProxy. +"""Docker-side pipelock helpers: image pin, container naming, and +the one-shot `pipelock tls init` host-side CA mint. The +prepare-time YAML rendering itself lives on the platform-neutral +`PipelockProxy` ABC — backends instantiate it directly. The per-container `.start()` / `.stop()` lifecycle was deleted in -PRD 0024 chunk 3 — compose-up owns the container lifecycle (PRD +PRD 0024 chunk 3; compose-up owns the container lifecycle (PRD 0018) and the bundle path (PRD 0024) collapses pipelock + egress -+ git-gate + supervise into one container. What remains here is -the prepare-time YAML rendering + the CA path constants the -compose renderer reads.""" ++ git-gate + supervise into one container.""" from __future__ import annotations @@ -17,7 +15,13 @@ import subprocess from pathlib import Path from ...log import die -from ...pipelock import PipelockProxy +# Re-exported for the compose renderer + smolmachines launch step +# (they used to import these from this module before they moved to +# the platform-neutral pipelock module). +from ...pipelock import ( # noqa: F401 + PIPELOCK_CA_CERT_IN_CONTAINER, + PIPELOCK_CA_KEY_IN_CONTAINER, +) # Pipelock image, pinned by digest. The digest is the multi-arch image @@ -30,19 +34,12 @@ 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}" - - -def pipelock_proxy_url(slug: str) -> str: - return f"http://{pipelock_container_name(slug)}:{PIPELOCK_PORT}" +# The URL egress dials for its upstream HTTPS_PROXY. egress and +# pipelock share the same container's network namespace inside the +# 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_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) cert.chmod(0o644) return (cert, key) - - -class DockerPipelockProxy(PipelockProxy): - """Docker-flavored PipelockProxy: inherits `.prepare()` from the - base, exposes the in-container CA paths the renderer reads. - Container lifecycle is owned by compose.""" - - CA_CERT_IN_CONTAINER = PIPELOCK_CA_CERT_IN_CONTAINER - CA_KEY_IN_CONTAINER = PIPELOCK_CA_KEY_IN_CONTAINER - diff --git a/claude_bottle/backend/docker/prepare.py b/claude_bottle/backend/docker/prepare.py index 882e2fc..1738fd0 100644 --- a/claude_bottle/backend/docker/prepare.py +++ b/claude_bottle/backend/docker/prepare.py @@ -14,13 +14,15 @@ 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 .bottle_state import ( BottleMetadata, agent_state_dir, @@ -35,8 +37,7 @@ from .bottle_state import ( supervise_state_dir, write_metadata, ) -from .pipelock import DockerPipelockProxy, pipelock_container_name -from .supervise import DockerSupervise, supervise_container_name +from .sidecar_bundle import sidecar_bundle_container_name def resolve_plan( @@ -49,10 +50,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] @@ -123,30 +124,18 @@ def resolve_plan( f"clean up old containers with 'docker rm -f '" ) - # Probe sidecar container names for orphans from a previous run. - # Sidecar names are deterministic from the slug; an orphan would - # surface as a docker-create conflict deep inside launch() with no - # actionable hint. Fail fast here with a cleanup pointer instead. - # Only probe sidecars this launch will actually try to create: - # pipelock always; git-gate when bottle.git is non-empty; - # egress when bottle.egress.routes is non-empty. - sidecar_probes: list[tuple[str, str]] = [ - ("pipelock", pipelock_container_name(slug)), - ] - if bottle.git: - 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." - ) + # Probe the sidecar-bundle container name for an orphan from a + # previous run. Otherwise a stale bundle surfaces as a + # docker-create conflict deep inside launch() with no actionable + # hint; failing fast here points at the cleanup command. + bundle_name = sidecar_bundle_container_name(slug) + if docker_mod.container_exists(bundle_name): + die( + f"sidecar bundle container '{bundle_name}' already exists. " + f"This is an orphan from a previous run; clean it up with " + f"'./cli.py cleanup' (or 'docker rm -f {bundle_name}') and " + f"retry." + ) # PRD 0018 chunk 2: prepare-time scratch files live under # ~/.claude-bottle/state/// so chunk 3's compose diff --git a/claude_bottle/backend/docker/provision/git.py b/claude_bottle/backend/docker/provision/git.py index 7dc91e0..b9fca81 100644 --- a/claude_bottle/backend/docker/provision/git.py +++ b/claude_bottle/backend/docker/provision/git.py @@ -19,11 +19,11 @@ import os import subprocess from pathlib import Path +from ....git_gate import GIT_GATE_HOSTNAME from ....log import info from ....manifest import GitEntry from .. import util as docker_mod from ..bottle_plan import DockerBottlePlan -from ..git_gate import git_gate_host 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` 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.""" if not entries: return "" - gate = git_gate_host(slug) out = [ "# claude-bottle git-gate (PRD 0008): every git operation against\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", ] 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") 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_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.write_text(content) config_file.chmod(0o600) diff --git a/claude_bottle/backend/docker/supervise.py b/claude_bottle/backend/docker/supervise.py deleted file mode 100644 index 3c0d899..0000000 --- a/claude_bottle/backend/docker/supervise.py +++ /dev/null @@ -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-` resolves to the bundle's IP.""" - return f"claude-bottle-supervise-{slug}" - - -class DockerSupervise(Supervise): - """Docker-flavored Supervise: inherits `.prepare()` from the base. - The supervise daemon's container lifecycle is owned by the - sidecar bundle (PRD 0024).""" diff --git a/claude_bottle/backend/smolmachines/bottle_plan.py b/claude_bottle/backend/smolmachines/bottle_plan.py index 1213553..3bffb6d 100644 --- a/claude_bottle/backend/smolmachines/bottle_plan.py +++ b/claude_bottle/backend/smolmachines/bottle_plan.py @@ -12,7 +12,11 @@ import sys from dataclasses import dataclass from pathlib import Path +from ...egress import EgressPlan +from ...git_gate import GitGatePlan from ...log import info +from ...pipelock import PipelockProxyPlan +from ...supervise import SupervisePlan from .. import BottlePlan from ..print_util import print_multi @@ -57,6 +61,20 @@ class SmolmachinesBottlePlan(BottlePlan): # empty when the agent has no prompt — claude-code reads it # via --append-system-prompt-file only when non-empty. 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: """Compact y/N preflight. Same shape as the Docker diff --git a/claude_bottle/backend/smolmachines/launch.py b/claude_bottle/backend/smolmachines/launch.py index c0aa550..1f55792 100644 --- a/claude_bottle/backend/smolmachines/launch.py +++ b/claude_bottle/backend/smolmachines/launch.py @@ -1,24 +1,50 @@ """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 -+ starts the smolvm guest pointed at the bundle's pinned IP via -the Smolfile's TSI allowlist, yields a `SmolmachinesBottle` -handle, tears everything down on context exit. +Brings up the per-bottle docker bridge + sidecar bundle (with +real daemons + their config files), creates + starts the smolvm +guest pointed at the bundle's pinned IP via TSI's +`--allow-cidr /32` allowlist, yields a +`SmolmachinesBottle` handle, tears everything down on context +exit. -Chunk-2d scope: smoke-test plumbing for the launch + exec round -trip. The bundle daemons aren't supplied with config files yet -(pipelock.yaml, routes.yaml, etc.); the bundle's init supervisor -exits cleanly when nothing is configured. Real provisioning + CA -install + the inner Plan plumbing land in chunk 4.""" +The bundle's daemons consume the inner Plans the docker backend +already produces: pipelock reads its yaml + CA from the +PipelockProxyPlan; egress reads routes + CAs from the EgressPlan ++ EGRESS_UPSTREAM_PROXY pointing at `127.0.0.1:8888` (bundle +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 +import dataclasses +import os from contextlib import ExitStack, contextmanager 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 smolvm as _smolvm from .bottle import SmolmachinesBottle from .bottle_plan import SmolmachinesBottlePlan @@ -34,40 +60,53 @@ def launch( via the ExitStack.""" stack = ExitStack() try: - # 1. Per-bottle docker bridge + bundle container. + # 1. Per-bottle docker bridge. network = _bundle.bundle_network_name(plan.slug) _bundle.create_bundle_network(network, plan.bundle_subnet, plan.bundle_gateway) stack.callback(_bundle.remove_bundle_network, network) - bundle_spec = _bundle.BundleLaunchSpec( - slug=plan.slug, - network_name=network, - subnet=plan.bundle_subnet, - gateway=plan.bundle_gateway, - bundle_ip=plan.bundle_ip, - # Chunk 2d: empty daemon set — the init supervisor - # logs "no daemons selected" and idles. Real daemon - # bringup with inner-Plan-driven env + volumes lands - # in chunk 4 alongside provisioning. - daemons_csv="", - # PRD 0023 chunk 3: pin egress to localhost INSIDE the - # bundle so the agent's TSI-permitted `:*` - # 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",), + # 2. Mint per-bottle CAs and update the inner Plans with + # their launch-time paths. pipelock always runs in the + # bundle; egress's CA is only minted when the bottle + # declares routes (otherwise egress runs idle without + # MITM and the CA files would be unused). + ca_cert_host, ca_key_host = pipelock_tls_init(plan.proxy_plan.yaml_path.parent) + proxy_plan = dataclasses.replace( + plan.proxy_plan, + ca_cert_host_path=ca_cert_host, + ca_key_host_path=ca_key_host, ) - _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) - # 2. smolvm VM. --from carries the pre-packed - # .smolmachine artifact (built by prepare); --allow-cidr - # + -e carry the per-bottle TSI allowlist + env. Smolfile - # isn't usable here — smolvm 0.8.0 makes `--from` and - # `--smolfile` mutually exclusive. + # 4. smolvm VM. --from carries the pre-packed .smolmachine + # artifact (built by prepare); --allow-cidr + -e carry the + # per-bottle TSI allowlist + env. Smolfile isn't usable + # here — smolvm 0.8.0 makes `--from` and `--smolfile` + # mutually exclusive. _smolvm.machine_create( plan.machine_name, from_path=plan.agent_from_path, @@ -78,14 +117,109 @@ def launch( _smolvm.machine_start(plan.machine_name) stack.callback(_smolvm.machine_stop, plan.machine_name) - # 3. 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. + # 5. Provision (CA / prompt / skills / git / supervise). 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) finally: 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)) diff --git a/claude_bottle/backend/smolmachines/prepare.py b/claude_bottle/backend/smolmachines/prepare.py index 0aee9ba..f023cee 100644 --- a/claude_bottle/backend/smolmachines/prepare.py +++ b/claude_bottle/backend/smolmachines/prepare.py @@ -15,8 +15,16 @@ from ...backend.docker.bottle_state import ( BottleMetadata, agent_state_dir, bottle_identity, + egress_state_dir, + git_gate_state_dir, + pipelock_state_dir, + supervise_state_dir, 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 .bottle_plan import SmolmachinesBottlePlan from .util import smolmachines_bundle_subnet, smolmachines_preflight @@ -86,6 +94,30 @@ def resolve_plan( 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 + # 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 # path always exists. Content is the agent's `prompt` # field (markdown body) — empty for agents with no prompt. @@ -118,6 +150,10 @@ def resolve_plan( agent_from_path=agent_from_path, guest_env=guest_env, prompt_file=prompt_file, + proxy_plan=proxy_plan, + git_gate_plan=git_gate_plan, + egress_plan=egress_plan, + supervise_plan=supervise_plan, ) diff --git a/claude_bottle/git_gate.py b/claude_bottle/git_gate.py index ae24fe9..95d2a1d 100644 --- a/claude_bottle/git_gate.py +++ b/claude_bottle/git_gate.py @@ -38,6 +38,11 @@ from .log import die 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]: return {} diff --git a/claude_bottle/pipelock.py b/claude_bottle/pipelock.py index c640e47..96c779f 100644 --- a/claude_bottle/pipelock.py +++ b/claude_bottle/pipelock.py @@ -17,7 +17,6 @@ Image pin: ghcr.io/luckypipewrench/pipelock@sha256: for tag 2.3.0. from __future__ import annotations -from abc import ABC, abstractmethod from dataclasses import dataclass from pathlib import Path from typing import cast @@ -47,6 +46,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 -------------------------------------------------- @@ -301,44 +315,41 @@ 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, hyphen-normalized) used as the suffix in every per-agent - resource name — the agent container, the pipelock container - (`claude-bottle-pipelock-`), the internal/egress - networks. It's stored on the returned plan so the backend's - start step can derive the sidecar's container name. + resource name — the agent container, the sidecar bundle + container, the internal/egress networks. It's stored on the + returned plan so the backend's launch step can derive those + names. - The CA paths the YAML references are the in-container paths - from the concrete subclass's class-level constants. The - host-side counterparts are generated by the launch step - (not here, so prepare stays side-effect-free on docker) and - added to the plan via `dataclasses.replace` before `.start`.""" + The CA paths the YAML references are the module-level + in-container constants. The host-side counterparts are + generated by the launch step (not here, so prepare stays + side-effect-free on docker) and added to the plan via + `dataclasses.replace` before the daemon starts.""" yaml_path = stage_dir / "pipelock.yaml" cfg = pipelock_build_config( bottle, - ca_cert_path=self.CA_CERT_IN_CONTAINER, - ca_key_path=self.CA_KEY_IN_CONTAINER, + ca_cert_path=PIPELOCK_CA_CERT_IN_CONTAINER, + ca_key_path=PIPELOCK_CA_KEY_IN_CONTAINER, ) yaml_path.write_text(pipelock_render_yaml(cfg)) yaml_path.chmod(0o600) diff --git a/claude_bottle/supervise.py b/claude_bottle/supervise.py index 6576527..11b1256 100644 --- a/claude_bottle/supervise.py +++ b/claude_bottle/supervise.py @@ -21,8 +21,8 @@ to the agent. This module defines the host-side library: dataclasses for the queue file shapes, queue read/write helpers, the audit log writer, and the diff renderer. The in-container sidecar lives in -claude_bottle/supervise_server.py; the Docker lifecycle in -claude_bottle/backend/docker/supervise.py. +claude_bottle/supervise_server.py; the supervise daemon's container +lifecycle is owned by the sidecar bundle (PRD 0024). For 0013 the supervisor's approval handlers are deliberately no-ops: on approval the audit log is written and the response file is diff --git a/tests/integration/test_capability_apply.py b/tests/integration/test_capability_apply.py index e45395c..02711cc 100644 --- a/tests/integration/test_capability_apply.py +++ b/tests/integration/test_capability_apply.py @@ -39,6 +39,9 @@ from claude_bottle.backend.docker.network import ( network_create_internal, network_remove, ) +from claude_bottle.backend.docker.sidecar_bundle import ( + sidecar_bundle_container_name, +) from tests._docker import skip_unless_docker @@ -101,9 +104,9 @@ class TestCapabilityApply(unittest.TestCase): capture_output=True, text=True, check=False, ) 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). - sidecar = f"claude-bottle-supervise-{self.slug}" + sidecar = sidecar_bundle_container_name(self.slug) subprocess.run( [ "docker", "run", "-d", diff --git a/tests/integration/test_pipelock_apply.py b/tests/integration/test_pipelock_apply.py index 54523a3..8e2f64a 100644 --- a/tests/integration/test_pipelock_apply.py +++ b/tests/integration/test_pipelock_apply.py @@ -35,9 +35,9 @@ from claude_bottle.backend.docker.network import ( from claude_bottle.backend.docker.pipelock import ( PIPELOCK_CA_CERT_IN_CONTAINER, PIPELOCK_CA_KEY_IN_CONTAINER, - DockerPipelockProxy, pipelock_tls_init, ) +from claude_bottle.pipelock import PipelockProxy from claude_bottle.backend.docker.pipelock_apply import ( PipelockApplyError, apply_allowlist_change, @@ -99,7 +99,7 @@ class TestPipelockApply(unittest.TestCase): the updated config.""" state_dir = pipelock_state_dir(self.slug) state_dir.mkdir(parents=True, exist_ok=True) - prep = DockerPipelockProxy().prepare( + prep = PipelockProxy().prepare( fixture_minimal().bottles["dev"], self.slug, state_dir, ) self.internal_net = network_create_internal(self.slug) diff --git a/tests/integration/test_sandbox_escape.py b/tests/integration/test_sandbox_escape.py index bd6c8ed..12f218d 100644 --- a/tests/integration/test_sandbox_escape.py +++ b/tests/integration/test_sandbox_escape.py @@ -402,7 +402,7 @@ class TestSandboxEscape(unittest.TestCase): ("aws", "TEST_SECRET_AWS"), ("generic", "TEST_SECRET_GENERIC"), ] - gate_host = f"claude-bottle-git-gate-{self._identity}" + gate_host = "git-gate" for name, var in shapes: with self.subTest(secret=name): diff --git a/tests/integration/test_smolmachines_launch.py b/tests/integration/test_smolmachines_launch.py index 7c9dd35..b8f907d 100644 --- a/tests/integration/test_smolmachines_launch.py +++ b/tests/integration/test_smolmachines_launch.py @@ -124,6 +124,32 @@ class TestSmolmachinesLaunch(unittest.TestCase): 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 :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): # provision_prompt copies the host-side prompt.txt into the # guest at /root/.claude-bottle-prompt.txt. The content diff --git a/tests/unit/test_compose.py b/tests/unit/test_compose.py index c6aa212..2a0ee95 100644 --- a/tests/unit/test_compose.py +++ b/tests/unit/test_compose.py @@ -112,7 +112,7 @@ def _egress_plan(routes: tuple[EgressRoute, ...] = ()) -> EgressPlan: mitmproxy_ca_host_path=STATE / "egress-ca" / "mitmproxy-ca.pem", mitmproxy_ca_cert_only_host_path=STATE / "egress-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=")] self.assertEqual(1, len(proxy_lines)) self.assertEqual( - f"HTTPS_PROXY=http://claude-bottle-pipelock-{SLUG}:8888", + "HTTPS_PROXY=http://pipelock:8888", proxy_lines[0], ) @@ -304,12 +304,11 @@ class TestSidecarBundleShape(unittest.TestCase): def test_internal_aliases_cover_pipelock_and_egress_shortnames(self): # 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"] aliases = set(sc["networks"]["internal"]["aliases"]) self.assertIn("egress", aliases) - self.assertIn(f"claude-bottle-pipelock-{SLUG}", aliases) - self.assertIn(f"claude-bottle-egress-{SLUG}", aliases) + self.assertIn("pipelock", aliases) def test_internal_aliases_omit_inactive_sidecars(self): # With no git-gate / supervise, those names are NOT aliased @@ -317,15 +316,14 @@ class TestSidecarBundleShape(unittest.TestCase): # listening inside the bundle. sc = self._render()["services"]["sidecars"] aliases = set(sc["networks"]["internal"]["aliases"]) - self.assertNotIn(f"claude-bottle-git-gate-{SLUG}", aliases) + self.assertNotIn("git-gate", aliases) self.assertNotIn("supervise", aliases) def test_internal_aliases_include_active_sidecars(self): sc = self._render(with_git=True, supervise=True)["services"]["sidecars"] 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(f"claude-bottle-supervise-{SLUG}", aliases) def test_daemons_csv_lists_only_active(self): # Egress + pipelock are always in the daemon set even when diff --git a/tests/unit/test_pipelock_yaml.py b/tests/unit/test_pipelock_yaml.py index d6a3df3..45772e0 100644 --- a/tests/unit/test_pipelock_yaml.py +++ b/tests/unit/test_pipelock_yaml.py @@ -12,10 +12,10 @@ import unittest from pathlib import Path from typing import Any, cast -from claude_bottle.backend.docker.pipelock import DockerPipelockProxy from claude_bottle.manifest import Manifest from claude_bottle.pipelock import ( DEFAULT_TLS_PASSTHROUGH, + PipelockProxy, pipelock_build_config, pipelock_render_yaml, ) @@ -54,7 +54,7 @@ class TestBuildConfig(unittest.TestCase): self.assertNotIn("tls_interception", cfg) def test_tls_interception_block_emitted_when_paths_supplied(self): - # PRD 0006: paths flow in via DockerPipelockProxy's in-container + # PRD 0006: paths flow in via the platform-neutral in-container # constants; this directly pins the dict shape. passthrough_domains # is baked in so LLM provider endpoints (api.anthropic.com) skip # MITM — pipelock's docs explicitly recommend this for LLM hosts, @@ -152,7 +152,7 @@ class TestRenderAndWrite(unittest.TestCase): self.assertNotIn("ssrf:", text) def test_prepare_writes_file_at_mode_600(self): - plan = DockerPipelockProxy().prepare( + plan = PipelockProxy().prepare( fixture_minimal().bottles["dev"], "demo", self.out_dir ) self.assertEqual(0o600, os.stat(plan.yaml_path).st_mode & 0o777) @@ -170,7 +170,7 @@ class TestRenderAndWrite(unittest.TestCase): }, "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, }) - plan = DockerPipelockProxy().prepare( + plan = PipelockProxy().prepare( manifest.bottles["dev"], "demo", self.out_dir ) content = plan.yaml_path.read_text() @@ -179,13 +179,13 @@ class TestRenderAndWrite(unittest.TestCase): self.assertNotIn("prompt-message", content) def test_render_emits_tls_interception_via_prepare(self): - """`DockerPipelockProxy.prepare` plumbs its in-container CA - constants through to the YAML. The block should land in the + """`PipelockProxy.prepare` plumbs the module-level in-container + CA constants through to the YAML. The block should land in the rendered output with `enabled: true`, the configured paths, and the baked LLM-provider passthrough list. The actual host-side CA generation happens in launch (not prepare), so this test exercises only the YAML rendering.""" - plan = DockerPipelockProxy().prepare( + plan = PipelockProxy().prepare( fixture_minimal().bottles["dev"], "demo", self.out_dir ) content = plan.yaml_path.read_text() diff --git a/tests/unit/test_provision_git.py b/tests/unit/test_provision_git.py index 8c2f6af..59c60df 100644 --- a/tests/unit/test_provision_git.py +++ b/tests/unit/test_provision_git.py @@ -9,15 +9,15 @@ from tests.fixtures import fixture_minimal, fixture_with_git class TestGitGateGitconfigRender(unittest.TestCase): def test_empty_entries_renders_nothing(self): 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): 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 - # container hostname (claude-bottle-git-gate-). + # short network alias (`git-gate`) inside the sidecar bundle. self.assertIn( - '[url "git://claude-bottle-git-gate-demo/claude-bottle.git"]', + '[url "git://git-gate/claude-bottle.git"]', out, ) self.assertIn( @@ -25,7 +25,7 @@ class TestGitGateGitconfigRender(unittest.TestCase): "ssh://git@gitea.dideric.is:30009/didericis/claude-bottle.git", out, ) - self.assertIn('[url "git://claude-bottle-git-gate-demo/foo.git"]', out) + self.assertIn('[url "git://git-gate/foo.git"]', out) self.assertIn( "\tinsteadOf = ssh://git@github.com/didericis/foo.git", out, @@ -37,7 +37,7 @@ class TestGitGateGitconfigRender(unittest.TestCase): # gate push and leave fetch on the original URL — exactly the # v1 design we've moved past. 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.assertNotIn("pushInsteadOf", out) diff --git a/tests/unit/test_smolmachines_provision.py b/tests/unit/test_smolmachines_provision.py index 49e02e2..d30ecb9 100644 --- a/tests/unit/test_smolmachines_provision.py +++ b/tests/unit/test_smolmachines_provision.py @@ -18,7 +18,10 @@ from claude_bottle.backend.smolmachines.provision import ( prompt as _prompt, 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.pipelock import PipelockProxyPlan def _plan( @@ -53,6 +56,24 @@ def _plan( agent_from_path=Path("/tmp/agent.smolmachine"), guest_env={}, 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, )