diff --git a/claude_bottle/backend/docker/provision/supervise.py b/claude_bottle/backend/docker/provision/supervise.py index 7618b12..5e121c6 100644 --- a/claude_bottle/backend/docker/provision/supervise.py +++ b/claude_bottle/backend/docker/provision/supervise.py @@ -1,10 +1,15 @@ """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. +Registers the per-bottle supervise sidecar as an HTTP MCP server in +the agent's claude-code config so the agent discovers the three +stuck-recovery MCP tools (cred-proxy-block, pipelock-block, +capability-block) at startup. + +Uses `claude mcp add` rather than writing JSON directly. claude-code +owns the on-disk config format (`~/.claude.json` `mcpServers` shape, +field names, scope semantics) and changes it between versions; the +official command handles whatever the installed version expects. 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 @@ -13,63 +18,48 @@ sidecar that isn't running. from __future__ import annotations -import json -import os import subprocess -from ....log import info +from ....log import info, warn 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 supervise_mcp_url() -> str: + return f"http://{SUPERVISE_HOSTNAME}:{SUPERVISE_PORT}/" 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.""" + """Run `claude mcp add` inside the agent container to register + the supervise sidecar in claude-code's user config. No-op when + bottle.supervise is False. + + Failure is logged but not fatal: the bottle still works (you + just can't call supervise tools from the agent until the entry + is added manually). The operator sees the warning at launch.""" 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]) + url = supervise_mcp_url() + argv = [ + "docker", "exec", "-u", "node", target, + "claude", "mcp", "add", + "--scope", "user", + "--transport", "http", + _SUPERVISE_MCP_NAME, + url, + ] + info(f"registering supervise MCP server in agent claude config → {url}") + r = subprocess.run(argv, capture_output=True, text=True, check=False) + if r.returncode != 0: + warn( + f"`claude mcp add supervise` failed (exit {r.returncode}): " + f"{(r.stderr or r.stdout or '').strip()}. Inside the bottle, " + f"register manually with: " + f"claude mcp add --scope user --transport http supervise {url}" + ) -__all__ = ["provision_supervise", "render_settings"] +__all__ = ["provision_supervise", "supervise_mcp_url"] diff --git a/tests/unit/test_provision_supervise.py b/tests/unit/test_provision_supervise.py index f5109c7..64f1b5e 100644 --- a/tests/unit/test_provision_supervise.py +++ b/tests/unit/test_provision_supervise.py @@ -1,37 +1,29 @@ -"""Unit: supervise MCP settings renderer (PRD 0013 follow-up). +"""Unit: supervise MCP provisioning (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.""" +The real provisioning runs `claude mcp add` inside the agent +container — exercised by the existing supervise integration test +chain once the agent container is brought up. Here we just cover +the URL computation so a regression in SUPERVISE_HOSTNAME / PORT +plumbing surfaces in unit CI.""" -import json import unittest -from claude_bottle.backend.docker.provision.supervise import render_settings +from claude_bottle.backend.docker.provision.supervise import supervise_mcp_url 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"]) +class TestSuperviseMcpUrl(unittest.TestCase): + def test_url_matches_sidecar_constants(self): self.assertEqual( f"http://{SUPERVISE_HOSTNAME}:{SUPERVISE_PORT}/", - sv["url"], + supervise_mcp_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())) + def test_url_is_http_not_https(self): + # The agent dials the sidecar on the internal docker network; + # no TLS termination, no CA trust juggling. If this ever + # needs HTTPS, the sidecar's listener side has to change too. + self.assertTrue(supervise_mcp_url().startswith("http://")) if __name__ == "__main__":