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:
2026-05-25 04:10:39 -04:00
parent d5ba253878
commit 4b2dbcdefd
8 changed files with 369 additions and 2 deletions
+4
View File
@@ -31,6 +31,7 @@ from .provision import cred_proxy as _cred_proxy
from .provision import git as _git from .provision import git as _git
from .provision import prompt as _prompt from .provision import prompt as _prompt
from .provision import skills as _skills from .provision import skills as _skills
from .supervise import DockerSupervise
class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanupPlan"]): class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanupPlan"]):
@@ -43,6 +44,7 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
self._proxy = DockerPipelockProxy() self._proxy = DockerPipelockProxy()
self._git_gate = DockerGitGate() self._git_gate = DockerGitGate()
self._cred_proxy = DockerCredProxy() self._cred_proxy = DockerCredProxy()
self._supervise = DockerSupervise()
def _resolve_plan(self, spec: BottleSpec, *, stage_dir: Path) -> DockerBottlePlan: def _resolve_plan(self, spec: BottleSpec, *, stage_dir: Path) -> DockerBottlePlan:
return _prepare.resolve_plan( return _prepare.resolve_plan(
@@ -51,6 +53,7 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
proxy=self._proxy, proxy=self._proxy,
git_gate=self._git_gate, git_gate=self._git_gate,
cred_proxy=self._cred_proxy, cred_proxy=self._cred_proxy,
supervise=self._supervise,
) )
@contextmanager @contextmanager
@@ -60,6 +63,7 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
proxy=self._proxy, proxy=self._proxy,
git_gate=self._git_gate, git_gate=self._git_gate,
cred_proxy=self._cred_proxy, cred_proxy=self._cred_proxy,
supervise=self._supervise,
provision=self.provision, provision=self.provision,
) as bottle: ) as bottle:
yield bottle yield bottle
@@ -16,6 +16,7 @@ from ...git_gate import GitGatePlan
from ...log import info from ...log import info
from ...manifest import Agent, Bottle from ...manifest import Agent, Bottle
from ...pipelock import PipelockProxyPlan, pipelock_effective_allowlist from ...pipelock import PipelockProxyPlan, pipelock_effective_allowlist
from ...supervise import SupervisePlan
from .. import BottlePlan from .. import BottlePlan
@@ -53,6 +54,9 @@ class DockerBottlePlan(BottlePlan):
proxy_plan: PipelockProxyPlan proxy_plan: PipelockProxyPlan
git_gate_plan: GitGatePlan git_gate_plan: GitGatePlan
cred_proxy_plan: CredProxyPlan 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 allowlist_summary: str
use_runsc: bool use_runsc: bool
@@ -116,6 +120,12 @@ class DockerBottlePlan(BottlePlan):
info(" cred-proxy : (none)") info(" cred-proxy : (none)")
info(f" egress : {self.allowlist_summary}") info(f" egress : {self.allowlist_summary}")
info(" tls intercept : pipelock (per-bottle ephemeral CA, generated at launch)") 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( info(
f"prompt : {len(v.agent.prompt)} chars; " f"prompt : {len(v.agent.prompt)} chars; "
f"first line: {v.prompt_first_line or '(empty)'}" f"first line: {v.prompt_first_line or '(empty)'}"
@@ -169,6 +179,14 @@ class DockerBottlePlan(BottlePlan):
"ca_fingerprint": None, "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": { "prompt": {
"length": len(v.agent.prompt), "length": len(v.agent.prompt),
"first_line": v.prompt_first_line, "first_line": v.prompt_first_line,
+26
View File
@@ -19,6 +19,7 @@ from typing import Callable, Generator
from ...log import die, info from ...log import die, info
from ...pipelock import pipelock_build_config, pipelock_render_yaml 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 network as network_mod
from . import util as docker_mod from . import util as docker_mod
from .bottle import DockerBottle from .bottle import DockerBottle
@@ -33,6 +34,7 @@ from .pipelock import (
pipelock_tls_init, pipelock_tls_init,
) )
from .provision.ca import AGENT_CA_BUNDLE, AGENT_CA_PATH 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. # Where the repo root lives, for `docker build` context. Computed once.
@@ -46,6 +48,7 @@ def launch(
proxy: DockerPipelockProxy, proxy: DockerPipelockProxy,
git_gate: DockerGitGate, git_gate: DockerGitGate,
cred_proxy: DockerCredProxy, cred_proxy: DockerCredProxy,
supervise: DockerSupervise,
provision: Callable[[DockerBottlePlan, str], str | None], provision: Callable[[DockerBottlePlan, str], str | None],
) -> Generator[DockerBottle, None, None]: ) -> Generator[DockerBottle, None, None]:
"""Build, launch, and provision a Docker bottle. Teardown on exit. """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) cred_proxy_name = cred_proxy.start(plan.cred_proxy_plan)
stack.callback(cred_proxy.stop, cred_proxy_name) 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) container = _run_agent_container(plan, internal_network)
stack.callback(docker_mod.force_remove_container, container) 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: for name in plan.forwarded_env:
docker_args.extend(["-e", name]) 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"]) docker_args.extend([plan.runtime_image, "sleep", "infinity"])
info(f"starting container {plan.container_name} from {plan.runtime_image}") info(f"starting container {plan.container_name} from {plan.runtime_image}")
+22
View File
@@ -14,6 +14,7 @@ import os
from pathlib import Path from pathlib import Path
from ... import pipelock from ... import pipelock
from ...cred_proxy import cred_proxy_render_routes
from ...env import ResolvedEnv, resolve_env from ...env import ResolvedEnv, resolve_env
from ...log import die from ...log import die
from .. import BottleSpec from .. import BottleSpec
@@ -26,6 +27,7 @@ from .cred_proxy import (
) )
from .git_gate import DockerGitGate, git_gate_container_name from .git_gate import DockerGitGate, git_gate_container_name
from .pipelock import DockerPipelockProxy, pipelock_container_name from .pipelock import DockerPipelockProxy, pipelock_container_name
from .supervise import DockerSupervise, supervise_container_name
def resolve_plan( def resolve_plan(
@@ -35,6 +37,7 @@ def resolve_plan(
proxy: DockerPipelockProxy, proxy: DockerPipelockProxy,
git_gate: DockerGitGate, git_gate: DockerGitGate,
cred_proxy: DockerCredProxy, cred_proxy: DockerCredProxy,
supervise: DockerSupervise,
) -> DockerBottlePlan: ) -> DockerBottlePlan:
"""Resolve Docker-specific names and write scratch files. Trusts """Resolve Docker-specific names and write scratch files. Trusts
that the agent and its skills/git-gate keys are present — 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))) sidecar_probes.append(("git-gate", git_gate_container_name(slug)))
if bottle.cred_proxy.routes: if bottle.cred_proxy.routes:
sidecar_probes.append(("cred-proxy", cred_proxy_container_name(slug))) 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: for label, sidecar_name in sidecar_probes:
if docker_mod.container_exists(sidecar_name): if docker_mod.container_exists(sidecar_name):
die( die(
@@ -111,6 +116,22 @@ def resolve_plan(
proxy_plan = proxy.prepare(bottle, slug, stage_dir) proxy_plan = proxy.prepare(bottle, slug, stage_dir)
git_gate_plan = git_gate.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) 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) resolved = resolve_env(manifest, spec.agent_name)
# Everything that should reach the bottle by-name (so its value # Everything that should reach the bottle by-name (so its value
# never lands on argv or in env_file) goes into one dict. Nothing # never lands on argv or in env_file) goes into one dict. Nothing
@@ -169,6 +190,7 @@ def resolve_plan(
proxy_plan=proxy_plan, proxy_plan=proxy_plan,
git_gate_plan=git_gate_plan, git_gate_plan=git_gate_plan,
cred_proxy_plan=cred_proxy_plan, cred_proxy_plan=cred_proxy_plan,
supervise_plan=supervise_plan,
allowlist_summary=allowlist_summary, allowlist_summary=allowlist_summary,
use_runsc=use_runsc, use_runsc=use_runsc,
) )
+131
View File
@@ -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}'"
)
+19 -2
View File
@@ -329,6 +329,13 @@ class Bottle:
git: tuple[GitEntry, ...] = () git: tuple[GitEntry, ...] = ()
cred_proxy: CredProxyConfig = field(default_factory=CredProxyConfig) cred_proxy: CredProxyConfig = field(default_factory=CredProxyConfig)
egress: BottleEgress = field(default_factory=BottleEgress) 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 @classmethod
def from_dict(cls, name: str, raw: object) -> "Bottle": def from_dict(cls, name: str, raw: object) -> "Bottle":
@@ -396,7 +403,17 @@ class Bottle:
else BottleEgress() 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) @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 # Frontmatter keys we accept on each entity. Anything not in these
# sets dies with a "did you mean" pointer — typos shouldn't silently # sets dies with a "did you mean" pointer — typos shouldn't silently
# ghost into an empty config. # 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_REQUIRED = frozenset({"bottle"})
_AGENT_KEYS_OPTIONAL = frozenset({"skills"}) _AGENT_KEYS_OPTIONAL = frozenset({"skills"})
# Claude Code subagent fields claude-bottle ignores at launch but # Claude Code subagent fields claude-bottle ignores at launch but
+87
View File
@@ -40,6 +40,7 @@ import json
import os import os
import time import time
import uuid import uuid
from abc import ABC, abstractmethod
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
@@ -418,6 +419,87 @@ def sha256_hex(content: str) -> str:
return hashlib.sha256(content.encode("utf-8")).hexdigest() 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 --------------------------------------------------------------- # --- Helpers ---------------------------------------------------------------
@@ -466,7 +548,10 @@ __all__ = [
"ACTION_OPERATOR_EDIT", "ACTION_OPERATOR_EDIT",
"AuditEntry", "AuditEntry",
"COMPONENT_FOR_TOOL", "COMPONENT_FOR_TOOL",
"CURRENT_CONFIG_ALLOWLIST",
"CURRENT_CONFIG_DIR_IN_AGENT", "CURRENT_CONFIG_DIR_IN_AGENT",
"CURRENT_CONFIG_DOCKERFILE",
"CURRENT_CONFIG_ROUTES",
"DEFAULT_POLL_INTERVAL_SEC", "DEFAULT_POLL_INTERVAL_SEC",
"Proposal", "Proposal",
"QUEUE_DIR_IN_CONTAINER", "QUEUE_DIR_IN_CONTAINER",
@@ -477,6 +562,8 @@ __all__ = [
"STATUS_REJECTED", "STATUS_REJECTED",
"SUPERVISE_HOSTNAME", "SUPERVISE_HOSTNAME",
"SUPERVISE_PORT", "SUPERVISE_PORT",
"Supervise",
"SupervisePlan",
"TOOLS", "TOOLS",
"TOOL_CAPABILITY_BLOCK", "TOOL_CAPABILITY_BLOCK",
"TOOL_CRED_PROXY_BLOCK", "TOOL_CRED_PROXY_BLOCK",
+62
View File
@@ -324,5 +324,67 @@ class TestToolConstants(unittest.TestCase):
self.assertNotIn(TOOL_CAPABILITY_BLOCK, supervise.COMPONENT_FOR_TOOL) 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__": if __name__ == "__main__":
unittest.main() unittest.main()