Merge pull request 'feat(smolmachines): thread inner Plans + bundle daemons run (PRD 0023 chunk 4b)' (#70) from prd-0023-chunk-4b-inner-plans into main
test / unit (push) Successful in 22s
test / integration (push) Successful in 40s

This commit was merged in pull request #70.
This commit is contained in:
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,
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),
]
+8 -14
View File
@@ -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:
+7 -22
View File
@@ -1,11 +1,11 @@
"""DockerEgress — the Docker-specific lifecycle for the
per-bottle egress sidecar (PRD 0017). Inherits the platform-
agnostic prepare step (route lift + routes.yaml render + token-env
map derivation) from `Egress`.
"""Docker-side egress helpers: port pin, in-container CA paths,
container naming, and the host-side mitmproxy CA mint. The
prepare-time routes-yaml rendering itself lives on the
platform-neutral `Egress` ABC — backends instantiate it directly.
Chunks 1+2 of the PRD: the lifecycle is implemented and wired into
launch.py — cred-proxy is gone. Chunk 3 retargets the cred-proxy-
block remediation flow (PRD 0014)."""
The per-container `.start()` / `.stop()` lifecycle was removed in
PRD 0024 chunk 3; the sidecar bundle (PRD 0024) runs egress
under its python init supervisor."""
from __future__ import annotations
@@ -13,7 +13,6 @@ import os
import subprocess
from pathlib import Path
from ...egress import Egress
from ...log import die
@@ -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]:
"""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."""
+5 -28
View File
@@ -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-<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 .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:
+18 -31
View File
@@ -1,14 +1,12 @@
"""DockerPipelockProxy — the Docker-specific implementation of the
sidecar's `.prepare()` step + in-container CA path constants.
Inherits the platform-agnostic YAML-config generation from
PipelockProxy.
"""Docker-side pipelock helpers: image pin, container naming, and
the one-shot `pipelock tls init` host-side CA mint. The
prepare-time YAML rendering itself lives on the platform-neutral
`PipelockProxy` ABC — backends instantiate it directly.
The per-container `.start()` / `.stop()` lifecycle was deleted in
PRD 0024 chunk 3 compose-up owns the container lifecycle (PRD
PRD 0024 chunk 3; compose-up owns the container lifecycle (PRD
0018) and the bundle path (PRD 0024) collapses pipelock + egress
+ git-gate + supervise into one container. What remains here is
the prepare-time YAML rendering + the CA path constants the
compose renderer reads."""
+ git-gate + supervise into one container."""
from __future__ import annotations
@@ -17,7 +15,13 @@ import subprocess
from pathlib import Path
from ...log import die
from ...pipelock import PipelockProxy
# Re-exported for the compose renderer + smolmachines launch step
# (they used to import these from this module before they moved to
# the platform-neutral pipelock module).
from ...pipelock import ( # noqa: F401
PIPELOCK_CA_CERT_IN_CONTAINER,
PIPELOCK_CA_KEY_IN_CONTAINER,
)
# Pipelock image, pinned by digest. The digest is the multi-arch image
@@ -30,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
+21 -32
View File
@@ -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 <name>'"
)
# 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/<slug>/<service>/ so chunk 3's compose
@@ -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)
-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 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
+178 -44
View File
@@ -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 <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
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 `<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",),
# 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))
@@ -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,
)
+5
View File
@@ -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 {}
+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 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-<slug>`), the internal/egress
networks. It's stored on the returned plan so the backend's
start step can derive the sidecar's container name.
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)
+2 -2
View File
@@ -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
+5 -2
View File
@@ -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",
+2 -2
View File
@@ -35,9 +35,9 @@ from claude_bottle.backend.docker.network import (
from claude_bottle.backend.docker.pipelock import (
PIPELOCK_CA_CERT_IN_CONTAINER,
PIPELOCK_CA_KEY_IN_CONTAINER,
DockerPipelockProxy,
pipelock_tls_init,
)
from claude_bottle.pipelock import PipelockProxy
from claude_bottle.backend.docker.pipelock_apply import (
PipelockApplyError,
apply_allowlist_change,
@@ -99,7 +99,7 @@ class TestPipelockApply(unittest.TestCase):
the updated config."""
state_dir = pipelock_state_dir(self.slug)
state_dir.mkdir(parents=True, exist_ok=True)
prep = DockerPipelockProxy().prepare(
prep = PipelockProxy().prepare(
fixture_minimal().bottles["dev"], self.slug, state_dir,
)
self.internal_net = network_create_internal(self.slug)
+1 -1
View File
@@ -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):
@@ -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 <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):
# provision_prompt copies the host-side prompt.txt into the
# 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_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
+7 -7
View File
@@ -12,10 +12,10 @@ import unittest
from pathlib import Path
from typing import Any, cast
from claude_bottle.backend.docker.pipelock import DockerPipelockProxy
from claude_bottle.manifest import Manifest
from claude_bottle.pipelock import (
DEFAULT_TLS_PASSTHROUGH,
PipelockProxy,
pipelock_build_config,
pipelock_render_yaml,
)
@@ -54,7 +54,7 @@ class TestBuildConfig(unittest.TestCase):
self.assertNotIn("tls_interception", cfg)
def test_tls_interception_block_emitted_when_paths_supplied(self):
# PRD 0006: paths flow in via DockerPipelockProxy's in-container
# PRD 0006: paths flow in via the platform-neutral in-container
# constants; this directly pins the dict shape. passthrough_domains
# is baked in so LLM provider endpoints (api.anthropic.com) skip
# MITM — pipelock's docs explicitly recommend this for LLM hosts,
@@ -152,7 +152,7 @@ class TestRenderAndWrite(unittest.TestCase):
self.assertNotIn("ssrf:", text)
def test_prepare_writes_file_at_mode_600(self):
plan = DockerPipelockProxy().prepare(
plan = PipelockProxy().prepare(
fixture_minimal().bottles["dev"], "demo", self.out_dir
)
self.assertEqual(0o600, os.stat(plan.yaml_path).st_mode & 0o777)
@@ -170,7 +170,7 @@ class TestRenderAndWrite(unittest.TestCase):
},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
})
plan = DockerPipelockProxy().prepare(
plan = PipelockProxy().prepare(
manifest.bottles["dev"], "demo", self.out_dir
)
content = plan.yaml_path.read_text()
@@ -179,13 +179,13 @@ class TestRenderAndWrite(unittest.TestCase):
self.assertNotIn("prompt-message", content)
def test_render_emits_tls_interception_via_prepare(self):
"""`DockerPipelockProxy.prepare` plumbs its in-container CA
constants through to the YAML. The block should land in the
"""`PipelockProxy.prepare` plumbs the module-level in-container
CA constants through to the YAML. The block should land in the
rendered output with `enabled: true`, the configured paths,
and the baked LLM-provider passthrough list. The actual
host-side CA generation happens in launch (not prepare), so
this test exercises only the YAML rendering."""
plan = DockerPipelockProxy().prepare(
plan = PipelockProxy().prepare(
fixture_minimal().bottles["dev"], "demo", self.out_dir
)
content = plan.yaml_path.read_text()
+6 -6
View File
@@ -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-<slug>).
# 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)
+21
View File
@@ -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,
)