From 6e46ca4478b49856387f7b96a27b111d9a7feb8a Mon Sep 17 00:00:00 2001 From: didericis Date: Mon, 25 May 2026 06:22:25 -0400 Subject: [PATCH] feat(supervise): provision agent-side MCP config so Claude sees the sidecar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --cwd # agent's claude sees supervise # agent calls cred-proxy-block via MCP ./cli.py dashboard # approve ./cli.py resume # restart with new capabilities Co-Authored-By: Claude Opus 4.7 --- claude_bottle/backend/__init__.py | 8 ++ claude_bottle/backend/docker/backend.py | 4 + .../backend/docker/provision/supervise.py | 75 +++++++++++++++++++ tests/unit/test_provision_supervise.py | 38 ++++++++++ 4 files changed, 125 insertions(+) create mode 100644 claude_bottle/backend/docker/provision/supervise.py create mode 100644 tests/unit/test_provision_supervise.py diff --git a/claude_bottle/backend/__init__.py b/claude_bottle/backend/__init__.py index 04132ad..972ce04 100644 --- a/claude_bottle/backend/__init__.py +++ b/claude_bottle/backend/__init__.py @@ -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 diff --git a/claude_bottle/backend/docker/backend.py b/claude_bottle/backend/docker/backend.py index 8be5dee..d828291 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 .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() diff --git a/claude_bottle/backend/docker/provision/supervise.py b/claude_bottle/backend/docker/provision/supervise.py new file mode 100644 index 0000000..7618b12 --- /dev/null +++ b/claude_bottle/backend/docker/provision/supervise.py @@ -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"] diff --git a/tests/unit/test_provision_supervise.py b/tests/unit/test_provision_supervise.py new file mode 100644 index 0000000..f5109c7 --- /dev/null +++ b/tests/unit/test_provision_supervise.py @@ -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() -- 2.52.0