feat(supervise): Docker lifecycle + bottle integration (PRD 0013)
Phase 3 of PRD 0013. Wires the supervise sidecar into bottle launch: - Manifest: bottle.supervise (bool, default False). Opt-in for v1 so existing bottles are unchanged. - supervise.py: adds SupervisePlan + abstract Supervise(ABC) with a prepare template that stages the per-bottle queue dir on the host and the current-config dir under stage_dir (routes.json + allowlist + Dockerfile). Stdlib-only so it still runs as the in-container shared helper. - backend/docker/supervise.py: DockerSupervise concrete start/stop. No egress network (the sidecar doesn't make outbound calls); just the bottle's internal network with network-alias "supervise" and a bind-mount of the host queue dir at /run/supervise/queue. - Prepare wires supervise.prepare into the DockerBottlePlan, derives routes_content from cred_proxy_plan, allowlist_content from pipelock_effective_allowlist, and dockerfile_content from the repo's Dockerfile. supervise sidecar added to the orphan probe. - Launch starts the supervise sidecar after pipelock + cred-proxy but before the agent (so DNS resolution for `supervise` is up on the agent's first tool call). - Agent container gets a read-only bind-mount of the current-config dir at /etc/claude-bottle/current-config when supervise is enabled. - bottle_plan print + to_dict surface the supervise state. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -31,6 +31,7 @@ from .provision import cred_proxy as _cred_proxy
|
||||
from .provision import git as _git
|
||||
from .provision import prompt as _prompt
|
||||
from .provision import skills as _skills
|
||||
from .supervise import DockerSupervise
|
||||
|
||||
|
||||
class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanupPlan"]):
|
||||
@@ -43,6 +44,7 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
|
||||
self._proxy = DockerPipelockProxy()
|
||||
self._git_gate = DockerGitGate()
|
||||
self._cred_proxy = DockerCredProxy()
|
||||
self._supervise = DockerSupervise()
|
||||
|
||||
def _resolve_plan(self, spec: BottleSpec, *, stage_dir: Path) -> DockerBottlePlan:
|
||||
return _prepare.resolve_plan(
|
||||
@@ -51,6 +53,7 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
|
||||
proxy=self._proxy,
|
||||
git_gate=self._git_gate,
|
||||
cred_proxy=self._cred_proxy,
|
||||
supervise=self._supervise,
|
||||
)
|
||||
|
||||
@contextmanager
|
||||
@@ -60,6 +63,7 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
|
||||
proxy=self._proxy,
|
||||
git_gate=self._git_gate,
|
||||
cred_proxy=self._cred_proxy,
|
||||
supervise=self._supervise,
|
||||
provision=self.provision,
|
||||
) as bottle:
|
||||
yield bottle
|
||||
|
||||
@@ -16,6 +16,7 @@ from ...git_gate import GitGatePlan
|
||||
from ...log import info
|
||||
from ...manifest import Agent, Bottle
|
||||
from ...pipelock import PipelockProxyPlan, pipelock_effective_allowlist
|
||||
from ...supervise import SupervisePlan
|
||||
from .. import BottlePlan
|
||||
|
||||
|
||||
@@ -53,6 +54,9 @@ class DockerBottlePlan(BottlePlan):
|
||||
proxy_plan: PipelockProxyPlan
|
||||
git_gate_plan: GitGatePlan
|
||||
cred_proxy_plan: CredProxyPlan
|
||||
# None when bottle.supervise is False. PRD 0013 supervise sidecar
|
||||
# is opt-in via the manifest's bottle.supervise field.
|
||||
supervise_plan: SupervisePlan | None
|
||||
allowlist_summary: str
|
||||
use_runsc: bool
|
||||
|
||||
@@ -116,6 +120,12 @@ class DockerBottlePlan(BottlePlan):
|
||||
info(" cred-proxy : (none)")
|
||||
info(f" egress : {self.allowlist_summary}")
|
||||
info(" tls intercept : pipelock (per-bottle ephemeral CA, generated at launch)")
|
||||
if self.supervise_plan is not None:
|
||||
info(
|
||||
f" supervise : enabled; queue at {self.supervise_plan.queue_dir}"
|
||||
)
|
||||
else:
|
||||
info(" supervise : disabled (set bottle.supervise=true to enable)")
|
||||
info(
|
||||
f"prompt : {len(v.agent.prompt)} chars; "
|
||||
f"first line: {v.prompt_first_line or '(empty)'}"
|
||||
@@ -169,6 +179,14 @@ class DockerBottlePlan(BottlePlan):
|
||||
"ca_fingerprint": None,
|
||||
},
|
||||
},
|
||||
"supervise": {
|
||||
"enabled": self.supervise_plan is not None,
|
||||
"queue_dir": (
|
||||
str(self.supervise_plan.queue_dir)
|
||||
if self.supervise_plan is not None
|
||||
else None
|
||||
),
|
||||
},
|
||||
"prompt": {
|
||||
"length": len(v.agent.prompt),
|
||||
"first_line": v.prompt_first_line,
|
||||
|
||||
@@ -19,6 +19,7 @@ from typing import Callable, Generator
|
||||
|
||||
from ...log import die, info
|
||||
from ...pipelock import pipelock_build_config, pipelock_render_yaml
|
||||
from ...supervise import CURRENT_CONFIG_DIR_IN_AGENT
|
||||
from . import network as network_mod
|
||||
from . import util as docker_mod
|
||||
from .bottle import DockerBottle
|
||||
@@ -33,6 +34,7 @@ from .pipelock import (
|
||||
pipelock_tls_init,
|
||||
)
|
||||
from .provision.ca import AGENT_CA_BUNDLE, AGENT_CA_PATH
|
||||
from .supervise import DockerSupervise
|
||||
|
||||
|
||||
# Where the repo root lives, for `docker build` context. Computed once.
|
||||
@@ -46,6 +48,7 @@ def launch(
|
||||
proxy: DockerPipelockProxy,
|
||||
git_gate: DockerGitGate,
|
||||
cred_proxy: DockerCredProxy,
|
||||
supervise: DockerSupervise,
|
||||
provision: Callable[[DockerBottlePlan, str], str | None],
|
||||
) -> Generator[DockerBottle, None, None]:
|
||||
"""Build, launch, and provision a Docker bottle. Teardown on exit.
|
||||
@@ -156,6 +159,19 @@ def launch(
|
||||
cred_proxy_name = cred_proxy.start(plan.cred_proxy_plan)
|
||||
stack.callback(cred_proxy.stop, cred_proxy_name)
|
||||
|
||||
# Supervise sidecar (PRD 0013). Opt-in via bottle.supervise.
|
||||
# Internal-network only — the sidecar makes no outbound calls.
|
||||
# Must come up BEFORE the agent so DNS resolution for
|
||||
# `supervise` succeeds on the agent's first tool call.
|
||||
if plan.supervise_plan is not None:
|
||||
supervise_plan = dataclasses.replace(
|
||||
plan.supervise_plan,
|
||||
internal_network=internal_network,
|
||||
)
|
||||
plan = dataclasses.replace(plan, supervise_plan=supervise_plan)
|
||||
supervise_name = supervise.start(plan.supervise_plan)
|
||||
stack.callback(supervise.stop, supervise_name)
|
||||
|
||||
container = _run_agent_container(plan, internal_network)
|
||||
stack.callback(docker_mod.force_remove_container, container)
|
||||
|
||||
@@ -196,6 +212,16 @@ def _run_agent_container(plan: DockerBottlePlan, internal_network: str) -> str:
|
||||
for name in plan.forwarded_env:
|
||||
docker_args.extend(["-e", name])
|
||||
|
||||
# PRD 0013: read-only current-config mount so the agent can read
|
||||
# routes.json / allowlist / Dockerfile before composing a
|
||||
# supervise tool-call proposal. Mounted from the per-bottle
|
||||
# stage_dir/current-config/ populated at prepare time.
|
||||
if plan.supervise_plan is not None:
|
||||
docker_args.extend([
|
||||
"-v",
|
||||
f"{plan.supervise_plan.current_config_dir}:{CURRENT_CONFIG_DIR_IN_AGENT}:ro",
|
||||
])
|
||||
|
||||
docker_args.extend([plan.runtime_image, "sleep", "infinity"])
|
||||
|
||||
info(f"starting container {plan.container_name} from {plan.runtime_image}")
|
||||
|
||||
@@ -14,6 +14,7 @@ import os
|
||||
from pathlib import Path
|
||||
|
||||
from ... import pipelock
|
||||
from ...cred_proxy import cred_proxy_render_routes
|
||||
from ...env import ResolvedEnv, resolve_env
|
||||
from ...log import die
|
||||
from .. import BottleSpec
|
||||
@@ -26,6 +27,7 @@ from .cred_proxy import (
|
||||
)
|
||||
from .git_gate import DockerGitGate, git_gate_container_name
|
||||
from .pipelock import DockerPipelockProxy, pipelock_container_name
|
||||
from .supervise import DockerSupervise, supervise_container_name
|
||||
|
||||
|
||||
def resolve_plan(
|
||||
@@ -35,6 +37,7 @@ def resolve_plan(
|
||||
proxy: DockerPipelockProxy,
|
||||
git_gate: DockerGitGate,
|
||||
cred_proxy: DockerCredProxy,
|
||||
supervise: DockerSupervise,
|
||||
) -> DockerBottlePlan:
|
||||
"""Resolve Docker-specific names and write scratch files. Trusts
|
||||
that the agent and its skills/git-gate keys are present —
|
||||
@@ -94,6 +97,8 @@ def resolve_plan(
|
||||
sidecar_probes.append(("git-gate", git_gate_container_name(slug)))
|
||||
if bottle.cred_proxy.routes:
|
||||
sidecar_probes.append(("cred-proxy", cred_proxy_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(
|
||||
@@ -111,6 +116,22 @@ def resolve_plan(
|
||||
proxy_plan = proxy.prepare(bottle, slug, stage_dir)
|
||||
git_gate_plan = git_gate.prepare(bottle, slug, stage_dir)
|
||||
cred_proxy_plan = cred_proxy.prepare(bottle, slug, stage_dir)
|
||||
supervise_plan = None
|
||||
if bottle.supervise:
|
||||
routes_content = cred_proxy_render_routes(cred_proxy_plan.routes) if cred_proxy_plan.routes else ""
|
||||
allowlist_content = "\n".join(pipelock.pipelock_effective_allowlist(bottle)) + "\n"
|
||||
# Current Dockerfile for the agent image. Read from the repo
|
||||
# root; for `--cwd` derived images the base Dockerfile is what
|
||||
# the agent should propose changes against (the derived layer
|
||||
# is just a workspace copy).
|
||||
dockerfile_path = Path(__file__).resolve().parent.parent.parent.parent / "Dockerfile"
|
||||
dockerfile_content = dockerfile_path.read_text() if dockerfile_path.is_file() else ""
|
||||
supervise_plan = supervise.prepare(
|
||||
slug, stage_dir,
|
||||
routes_content=routes_content,
|
||||
allowlist_content=allowlist_content,
|
||||
dockerfile_content=dockerfile_content,
|
||||
)
|
||||
resolved = resolve_env(manifest, spec.agent_name)
|
||||
# Everything that should reach the bottle by-name (so its value
|
||||
# never lands on argv or in env_file) goes into one dict. Nothing
|
||||
@@ -169,6 +190,7 @@ def resolve_plan(
|
||||
proxy_plan=proxy_plan,
|
||||
git_gate_plan=git_gate_plan,
|
||||
cred_proxy_plan=cred_proxy_plan,
|
||||
supervise_plan=supervise_plan,
|
||||
allowlist_summary=allowlist_summary,
|
||||
use_runsc=use_runsc,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
"""DockerSupervise — the Docker-specific lifecycle for the per-bottle
|
||||
supervise sidecar (PRD 0013). Inherits the platform-agnostic prepare
|
||||
step (queue dir + current-config staging) from `Supervise`."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from ...log import die, info, warn
|
||||
from ...supervise import (
|
||||
QUEUE_DIR_IN_CONTAINER,
|
||||
SUPERVISE_HOSTNAME,
|
||||
SUPERVISE_PORT,
|
||||
Supervise,
|
||||
SupervisePlan,
|
||||
)
|
||||
from . import util as docker_mod
|
||||
|
||||
|
||||
SUPERVISE_IMAGE = os.environ.get(
|
||||
"CLAUDE_BOTTLE_SUPERVISE_IMAGE",
|
||||
"claude-bottle-supervise:latest",
|
||||
)
|
||||
|
||||
SUPERVISE_DOCKERFILE = "Dockerfile.supervise"
|
||||
|
||||
_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent)
|
||||
|
||||
|
||||
def supervise_container_name(slug: str) -> str:
|
||||
return f"claude-bottle-supervise-{slug}"
|
||||
|
||||
|
||||
def supervise_url() -> str:
|
||||
"""Base URL the agent's MCP client dials. Stable across bottles
|
||||
because the sidecar attaches `--network-alias supervise` on the
|
||||
internal network."""
|
||||
return f"http://{SUPERVISE_HOSTNAME}:{SUPERVISE_PORT}"
|
||||
|
||||
|
||||
def build_supervise_image() -> None:
|
||||
"""Build the supervise image from `Dockerfile.supervise`. Called
|
||||
by `DockerSupervise.start`; exposed at module level so tests can
|
||||
build it without running the full launch pipeline."""
|
||||
docker_mod.build_image(SUPERVISE_IMAGE, _REPO_DIR, dockerfile=SUPERVISE_DOCKERFILE)
|
||||
|
||||
|
||||
class DockerSupervise(Supervise):
|
||||
"""Brings the supervise sidecar up and down via Docker."""
|
||||
|
||||
def start(self, plan: SupervisePlan) -> str:
|
||||
"""Boot the supervise sidecar:
|
||||
1. Build the supervise image (no-op when cache is hot).
|
||||
2. `docker create` on the internal network with
|
||||
`--network-alias supervise` and SUPERVISE_BOTTLE_SLUG in
|
||||
the environ.
|
||||
3. Bind-mount the host queue dir at /run/supervise/queue.
|
||||
4. `docker start`.
|
||||
No egress network — the supervise sidecar does not make
|
||||
outbound calls. Returns the container name."""
|
||||
if not plan.internal_network:
|
||||
die("DockerSupervise.start: plan.internal_network must be set before start")
|
||||
if not plan.queue_dir.is_dir():
|
||||
die(
|
||||
f"DockerSupervise.start: queue dir missing at {plan.queue_dir}; "
|
||||
f"Supervise.prepare must run first"
|
||||
)
|
||||
|
||||
build_supervise_image()
|
||||
|
||||
name = supervise_container_name(plan.slug)
|
||||
info(f"starting supervise sidecar {name} on network {plan.internal_network}")
|
||||
|
||||
create_args = [
|
||||
"docker", "create",
|
||||
"--name", name,
|
||||
"--network", plan.internal_network,
|
||||
"--network-alias", SUPERVISE_HOSTNAME,
|
||||
"-e", f"SUPERVISE_BOTTLE_SLUG={plan.slug}",
|
||||
"-e", f"SUPERVISE_QUEUE_DIR={QUEUE_DIR_IN_CONTAINER}",
|
||||
"-e", f"SUPERVISE_PORT={SUPERVISE_PORT}",
|
||||
"-v", f"{plan.queue_dir}:{QUEUE_DIR_IN_CONTAINER}",
|
||||
SUPERVISE_IMAGE,
|
||||
]
|
||||
|
||||
create_result = subprocess.run(
|
||||
create_args, capture_output=True, text=True, check=False,
|
||||
)
|
||||
if create_result.returncode != 0:
|
||||
die(
|
||||
f"failed to create supervise sidecar {name}: "
|
||||
f"{create_result.stderr.strip()}"
|
||||
)
|
||||
|
||||
start_result = subprocess.run(
|
||||
["docker", "start", name], capture_output=True, text=True, check=False,
|
||||
)
|
||||
if start_result.returncode != 0:
|
||||
subprocess.run(
|
||||
["docker", "rm", "-f", name],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=False,
|
||||
)
|
||||
die(
|
||||
f"failed to start supervise sidecar {name}: "
|
||||
f"{start_result.stderr.strip()}"
|
||||
)
|
||||
|
||||
return name
|
||||
|
||||
def stop(self, target: str) -> None:
|
||||
"""Idempotent: missing container is success."""
|
||||
if subprocess.run(
|
||||
["docker", "inspect", target],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=False,
|
||||
).returncode == 0:
|
||||
if subprocess.run(
|
||||
["docker", "rm", "-f", target],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=False,
|
||||
).returncode != 0:
|
||||
warn(
|
||||
f"failed to remove supervise sidecar {target}; "
|
||||
f"clean up with 'docker rm -f {target}'"
|
||||
)
|
||||
Reference in New Issue
Block a user