feat(supervise): provision agent-side MCP config so Claude sees the sidecar
test / unit (pull_request) Successful in 17s
test / integration (pull_request) Successful in 1m30s

The supervise sidecar (PRD 0013) has been serving MCP at
http://supervise:9100/ since it landed, but the in-bottle Claude
Code had no `.mcp.json` or settings pointing there — so the agent
couldn't actually call cred-proxy-block / pipelock-block /
capability-block as tools. To exercise the flow you had to curl
the sidecar from a sibling container.

This closes that last mile.

- claude_bottle/backend/docker/provision/supervise.py (new):
  provision_supervise(plan, target) writes
  ~/.claude/settings.json into the running agent container with an
  mcpServers.supervise entry of type http pointing at the
  per-bottle sidecar. No-op when bottle.supervise is False.
- BottleBackend.provision orchestrator gains provision_supervise as
  the last step (after CA, prompt, skills, git, cred-proxy). Default
  impl is a no-op so non-Docker backends aren't forced to implement it.
- DockerBottleBackend wires it through to the new module.
- Test covers the rendered settings shape so a future regression in
  the MCP entry format would surface in unit-level CI.

To test the full flow end-to-end now:
  ./cli.py start <agent> --cwd       # agent's claude sees supervise
  # agent calls cred-proxy-block via MCP
  ./cli.py dashboard                  # approve
  ./cli.py resume <identity>          # restart with new capabilities

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 06:22:25 -04:00
parent 27b05f9452
commit 6e46ca4478
4 changed files with 125 additions and 0 deletions
+8
View File
@@ -229,6 +229,7 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
self.provision_skills(plan, target)
self.provision_git(plan, target)
self.provision_cred_proxy(plan, target)
self.provision_supervise(plan, target)
return prompt_path
def provision_ca(self, plan: PlanT, target: str) -> None:
@@ -263,6 +264,13 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
Default impl is a no-op for backends that don't yet support
the cred-proxy sidecar; the Docker backend overrides."""
def provision_supervise(self, plan: PlanT, target: str) -> None:
"""Write the in-bottle Claude Code MCP config so the agent
discovers the per-bottle supervise sidecar (PRD 0013).
No-op when bottle.supervise is False or the backend doesn't
support the supervise sidecar yet. The Docker backend
overrides."""
@abstractmethod
def prepare_cleanup(self) -> CleanupT:
"""Enumerate orphaned resources from previous bottles. No side
+4
View File
@@ -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 .provision import supervise as _supervise_prov
from .supervise import DockerSupervise
@@ -83,6 +84,9 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
def provision_cred_proxy(self, plan: DockerBottlePlan, target: str) -> None:
_cred_proxy.provision_cred_proxy(plan, target)
def provision_supervise(self, plan: DockerBottlePlan, target: str) -> None:
_supervise_prov.provision_supervise(plan, target)
def prepare_cleanup(self) -> DockerBottleCleanupPlan:
return _cleanup.prepare_cleanup()
@@ -0,0 +1,75 @@
"""Supervise sidecar provisioning inside a running Docker bottle
(PRD 0013).
Writes ~/.claude/settings.json with an `mcpServers.supervise` entry
pointing at the per-bottle supervise sidecar so the in-bottle Claude
Code discovers the three stuck-recovery MCP tools (cred-proxy-block,
pipelock-block, capability-block) at startup.
No-op when bottle.supervise is False — bottles that haven't opted
into the supervise sidecar shouldn't get an MCP entry pointing at a
sidecar that isn't running.
"""
from __future__ import annotations
import json
import os
import subprocess
from ....log import info
from ....supervise import SUPERVISE_HOSTNAME, SUPERVISE_PORT
from .. import util as docker_mod
from ..bottle_plan import DockerBottlePlan
_AGENT_HOME_DEFAULT = "/home/node"
_SETTINGS_REL_PATH = ".claude/settings.json"
_SUPERVISE_MCP_NAME = "supervise"
def render_settings() -> str:
"""The settings.json content the agent reads on startup. Stable
shape — only the URL is parameterized in case CLAUDE_BOTTLE_*
env overrides change the supervise hostname/port someday."""
cfg = {
"mcpServers": {
_SUPERVISE_MCP_NAME: {
"type": "http",
"url": f"http://{SUPERVISE_HOSTNAME}:{SUPERVISE_PORT}/",
},
},
}
return json.dumps(cfg, indent=2) + "\n"
def provision_supervise(plan: DockerBottlePlan, target: str) -> None:
"""Drop ~/.claude/settings.json into the running agent container
when bottle.supervise is True. No-op otherwise."""
if plan.supervise_plan is None:
return
container_home = os.environ.get(
"CLAUDE_BOTTLE_CONTAINER_HOME", _AGENT_HOME_DEFAULT,
)
settings_in_container = f"{container_home}/{_SETTINGS_REL_PATH}"
settings_dir_in_container = settings_in_container.rsplit("/", 1)[0]
host_path = plan.stage_dir / "agent_claude_settings.json"
host_path.write_text(render_settings())
host_path.chmod(0o644)
info(f"writing {settings_in_container} (supervise MCP server entry)")
# The Dockerfile creates ~/.claude.json at the top of HOME but
# not the ~/.claude/ subdir, so make sure it exists before cp.
docker_mod.docker_exec_root(target, ["mkdir", "-p", settings_dir_in_container])
subprocess.run(
["docker", "cp", str(host_path), f"{target}:{settings_in_container}"],
stdout=subprocess.DEVNULL,
check=True,
)
docker_mod.docker_exec_root(target, ["chown", "-R", "node:node", settings_dir_in_container])
docker_mod.docker_exec_root(target, ["chmod", "644", settings_in_container])
__all__ = ["provision_supervise", "render_settings"]
+38
View File
@@ -0,0 +1,38 @@
"""Unit: supervise MCP settings renderer (PRD 0013 follow-up).
The docker cp / chown side of provision_supervise is exercised by
the existing supervise integration test once the agent container is
brought up; here we cover the pure render path so a settings.json
shape regression would surface in unit-level CI."""
import json
import unittest
from claude_bottle.backend.docker.provision.supervise import render_settings
from claude_bottle.supervise import SUPERVISE_HOSTNAME, SUPERVISE_PORT
class TestRenderSettings(unittest.TestCase):
def test_output_is_valid_json(self):
json.loads(render_settings())
def test_has_mcp_servers_supervise_http_entry(self):
cfg = json.loads(render_settings())
servers = cfg["mcpServers"]
self.assertIn("supervise", servers)
sv = servers["supervise"]
self.assertEqual("http", sv["type"])
self.assertEqual(
f"http://{SUPERVISE_HOSTNAME}:{SUPERVISE_PORT}/",
sv["url"],
)
def test_only_supervise_server_is_emitted(self):
cfg = json.loads(render_settings())
# Keep the provisioner narrowly scoped — it owns just the
# supervise entry, no other tools/servers.
self.assertEqual({"supervise"}, set(cfg["mcpServers"].keys()))
if __name__ == "__main__":
unittest.main()