diff --git a/claude_bottle/backend/docker/backend.py b/claude_bottle/backend/docker/backend.py index 3ea0c0e..8be5dee 100644 --- a/claude_bottle/backend/docker/backend.py +++ b/claude_bottle/backend/docker/backend.py @@ -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 diff --git a/claude_bottle/backend/docker/bottle_plan.py b/claude_bottle/backend/docker/bottle_plan.py index e02ca9c..ce0d26f 100644 --- a/claude_bottle/backend/docker/bottle_plan.py +++ b/claude_bottle/backend/docker/bottle_plan.py @@ -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, diff --git a/claude_bottle/backend/docker/launch.py b/claude_bottle/backend/docker/launch.py index c59fb7f..f8a6def 100644 --- a/claude_bottle/backend/docker/launch.py +++ b/claude_bottle/backend/docker/launch.py @@ -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}") diff --git a/claude_bottle/backend/docker/prepare.py b/claude_bottle/backend/docker/prepare.py index 8c23f38..531eb43 100644 --- a/claude_bottle/backend/docker/prepare.py +++ b/claude_bottle/backend/docker/prepare.py @@ -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, ) diff --git a/claude_bottle/backend/docker/supervise.py b/claude_bottle/backend/docker/supervise.py new file mode 100644 index 0000000..ae4af26 --- /dev/null +++ b/claude_bottle/backend/docker/supervise.py @@ -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}'" + ) diff --git a/claude_bottle/manifest.py b/claude_bottle/manifest.py index babbdc8..fe33ddb 100644 --- a/claude_bottle/manifest.py +++ b/claude_bottle/manifest.py @@ -329,6 +329,13 @@ class Bottle: git: tuple[GitEntry, ...] = () cred_proxy: CredProxyConfig = field(default_factory=CredProxyConfig) egress: BottleEgress = field(default_factory=BottleEgress) + # Opt-in per-bottle stuck-recovery sidecar (PRD 0013). When true, + # the launch step brings up a supervise sidecar that exposes three + # MCP tools to the agent (cred-proxy-block, pipelock-block, + # capability-block) plus mounts the current-config dir read-only + # into the agent at /etc/claude-bottle/current-config. False (the + # default) skips the sidecar and the mount. + supervise: bool = False @classmethod def from_dict(cls, name: str, raw: object) -> "Bottle": @@ -396,7 +403,17 @@ class Bottle: else BottleEgress() ) - return cls(env=env, git=git, cred_proxy=cred_proxy, egress=egress) + supervise_raw = d.get("supervise", False) + if not isinstance(supervise_raw, bool): + die( + f"bottle '{name}' supervise must be a boolean " + f"(was {type(supervise_raw).__name__})" + ) + + return cls( + env=env, git=git, cred_proxy=cred_proxy, egress=egress, + supervise=supervise_raw, + ) @dataclass(frozen=True) @@ -747,7 +764,7 @@ _FILENAME_RX = re.compile(r"^[a-z][a-z0-9-]*$") # Frontmatter keys we accept on each entity. Anything not in these # sets dies with a "did you mean" pointer — typos shouldn't silently # ghost into an empty config. -_BOTTLE_KEYS = frozenset({"env", "git", "cred_proxy", "egress"}) +_BOTTLE_KEYS = frozenset({"env", "git", "cred_proxy", "egress", "supervise"}) _AGENT_KEYS_REQUIRED = frozenset({"bottle"}) _AGENT_KEYS_OPTIONAL = frozenset({"skills"}) # Claude Code subagent fields claude-bottle ignores at launch but diff --git a/claude_bottle/supervise.py b/claude_bottle/supervise.py index f0ed4c2..cb07241 100644 --- a/claude_bottle/supervise.py +++ b/claude_bottle/supervise.py @@ -40,6 +40,7 @@ import json import os import time import uuid +from abc import ABC, abstractmethod from dataclasses import dataclass from datetime import datetime, timezone from pathlib import Path @@ -418,6 +419,87 @@ def sha256_hex(content: str) -> str: return hashlib.sha256(content.encode("utf-8")).hexdigest() +# --- Sidecar plan + abstract lifecycle ------------------------------------- + + +# Filenames inside the per-bottle current-config dir. The agent reads +# these (read-only) from CURRENT_CONFIG_DIR_IN_AGENT and proposes +# modified versions back via the three MCP tools. +CURRENT_CONFIG_ROUTES = "routes.json" +CURRENT_CONFIG_ALLOWLIST = "allowlist" +CURRENT_CONFIG_DOCKERFILE = "Dockerfile" + + +@dataclass(frozen=True) +class SupervisePlan: + """Output of Supervise.prepare; consumed by .start. + + `queue_dir` is the host directory bind-mounted into the sidecar + at /run/supervise/queue. `current_config_dir` is the host + directory bind-mounted (read-only) into the *agent* container at + /etc/claude-bottle/current-config, holding routes.json + allowlist + + Dockerfile so the agent can read them before composing a + proposal. `internal_network` is empty at prepare time; the + backend's launch step fills it via dataclasses.replace before + calling .start.""" + + slug: str + queue_dir: Path + current_config_dir: Path + internal_network: str = "" + + +class Supervise(ABC): + """Per-bottle supervise sidecar. Encapsulates the host-side + prepare (queue dir + current-config staging); the sidecar's + start/stop lifecycle is backend-specific.""" + + def prepare( + self, + slug: str, + stage_dir: Path, + *, + routes_content: str = "", + allowlist_content: str = "", + dockerfile_content: str = "", + ) -> SupervisePlan: + """Stage the per-bottle queue dir on the host and the + current-config dir under `stage_dir`. Returns the plan; + `internal_network` must be set by the launch step before + .start runs.""" + queue_dir = queue_dir_for_slug(slug) + queue_dir.mkdir(parents=True, exist_ok=True) + current_config_dir = stage_dir / "current-config" + current_config_dir.mkdir(parents=True, exist_ok=True) + (current_config_dir / CURRENT_CONFIG_ROUTES).write_text( + routes_content or '{"routes": []}\n' + ) + (current_config_dir / CURRENT_CONFIG_ALLOWLIST).write_text(allowlist_content) + (current_config_dir / CURRENT_CONFIG_DOCKERFILE).write_text(dockerfile_content) + for name in ( + CURRENT_CONFIG_ROUTES, + CURRENT_CONFIG_ALLOWLIST, + CURRENT_CONFIG_DOCKERFILE, + ): + (current_config_dir / name).chmod(0o644) + return SupervisePlan( + slug=slug, + queue_dir=queue_dir, + current_config_dir=current_config_dir, + ) + + @abstractmethod + def start(self, plan: SupervisePlan) -> str: + """Bring up the supervise sidecar according to `plan`. Returns + the target string identifying the running instance — the same + value to pass to `.stop`. Backend-specific.""" + + @abstractmethod + def stop(self, target: str) -> None: + """Tear down the supervise sidecar identified by `target`. + Idempotent: a missing target is success.""" + + # --- Helpers --------------------------------------------------------------- @@ -466,7 +548,10 @@ __all__ = [ "ACTION_OPERATOR_EDIT", "AuditEntry", "COMPONENT_FOR_TOOL", + "CURRENT_CONFIG_ALLOWLIST", "CURRENT_CONFIG_DIR_IN_AGENT", + "CURRENT_CONFIG_DOCKERFILE", + "CURRENT_CONFIG_ROUTES", "DEFAULT_POLL_INTERVAL_SEC", "Proposal", "QUEUE_DIR_IN_CONTAINER", @@ -477,6 +562,8 @@ __all__ = [ "STATUS_REJECTED", "SUPERVISE_HOSTNAME", "SUPERVISE_PORT", + "Supervise", + "SupervisePlan", "TOOLS", "TOOL_CAPABILITY_BLOCK", "TOOL_CRED_PROXY_BLOCK", diff --git a/tests/unit/test_supervise.py b/tests/unit/test_supervise.py index 700ba48..90f54f6 100644 --- a/tests/unit/test_supervise.py +++ b/tests/unit/test_supervise.py @@ -324,5 +324,67 @@ class TestToolConstants(unittest.TestCase): self.assertNotIn(TOOL_CAPABILITY_BLOCK, supervise.COMPONENT_FOR_TOOL) +class _StubSupervise(supervise.Supervise): + """Concrete Supervise subclass for testing the prepare template.""" + + def start(self, plan): + return f"stub-{plan.slug}" + + def stop(self, target): + return None + + +class TestSupervisePrepare(unittest.TestCase): + def setUp(self): + self._tmp = tempfile.TemporaryDirectory(prefix="supervise-prepare-test.") + self._home_patch = self._patch_home(Path(self._tmp.name)) + self.stage_dir = Path(self._tmp.name) / "stage" + self.stage_dir.mkdir() + + def tearDown(self): + self._home_patch() + self._tmp.cleanup() + + def _patch_home(self, fake_home: Path): + original = supervise.claude_bottle_root + + def fake_root() -> Path: + return fake_home / ".claude-bottle" + + supervise.claude_bottle_root = fake_root # type: ignore[assignment] + return lambda: setattr(supervise, "claude_bottle_root", original) + + def test_prepare_creates_queue_and_current_config(self): + plan = _StubSupervise().prepare( + "dev", self.stage_dir, + routes_content='{"routes": [{"path": "/x/"}]}\n', + allowlist_content="example.com\n", + dockerfile_content="FROM python:3.13\n", + ) + self.assertTrue(plan.queue_dir.is_dir()) + self.assertTrue(plan.current_config_dir.is_dir()) + self.assertEqual( + '{"routes": [{"path": "/x/"}]}\n', + (plan.current_config_dir / "routes.json").read_text(), + ) + self.assertEqual( + "example.com\n", + (plan.current_config_dir / "allowlist").read_text(), + ) + self.assertEqual( + "FROM python:3.13\n", + (plan.current_config_dir / "Dockerfile").read_text(), + ) + self.assertEqual("dev", plan.slug) + self.assertEqual("", plan.internal_network) + + def test_prepare_defaults_routes_to_empty_when_absent(self): + plan = _StubSupervise().prepare("dev", self.stage_dir) + self.assertEqual( + '{"routes": []}\n', + (plan.current_config_dir / "routes.json").read_text(), + ) + + if __name__ == "__main__": unittest.main()