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
+62
View File
@@ -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()