diff --git a/claude_bottle/backend/docker/capability_apply.py b/claude_bottle/backend/docker/capability_apply.py new file mode 100644 index 0000000..414e59f --- /dev/null +++ b/claude_bottle/backend/docker/capability_apply.py @@ -0,0 +1,210 @@ +"""capability_apply — host-side orchestrator for capability-block +remediation (PRD 0016). + +On approval of a capability-block proposal, the dashboard calls +apply_capability_change(slug, new_dockerfile) which: + + 1. Snapshots the agent's transcript dir to + ~/.claude-bottle/state//transcript/ (best-effort). + 2. Pushes the agent's working tree via `git push` (best-effort — + no upstream / no commits / no git repo all skip with a log). + 3. Writes the new Dockerfile to + ~/.claude-bottle/state//Dockerfile (PRD 0016 Phase 1 + state). The next `cli.py start ` picks it up. + 4. Force-removes the agent container + all sidecars + the + per-bottle networks. Idempotent — missing resources are not + errors. + +Returns (before, after) Dockerfile contents so the dashboard can +record / render the diff. (capability-block has no audit log per +PRD 0013 — the per-bottle Dockerfile state is its own record.) + +This is "fire-and-forget" from the agent's perspective: by the time +the dashboard writes the response file the supervise sidecar is +gone, so the agent's tool call connection drops without ever +receiving the response. The replacement agent (next manual +`cli.py start`) sees the new Dockerfile and starts from there. +v1 does not auto-relaunch — see PRD 0016's capability-block return +semantics open question. +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +from pathlib import Path + +from ...log import info, warn +from .bottle_state import ( + per_bottle_dockerfile, + per_bottle_dockerfile_path, + transcript_snapshot_dir, + write_per_bottle_dockerfile, +) + + +# Agent home inside the container (per the repo Dockerfile's +# `USER node` + `WORKDIR /home/node`). Used to locate the transcript +# dir + the workspace dir for git push. +_AGENT_HOME_IN_CONTAINER = "/home/node" +_AGENT_TRANSCRIPT_IN_CONTAINER = f"{_AGENT_HOME_IN_CONTAINER}/.claude" +_AGENT_WORKSPACE_IN_CONTAINER = f"{_AGENT_HOME_IN_CONTAINER}/workspace" + +# Per-bottle resource name patterns (mirroring prepare.py / +# the various sidecar modules). The agent container's name is the +# slug with no infix; sidecars carry an infix like cred-proxy. +def _agent_container_name(slug: str) -> str: + return f"claude-bottle-{slug}" + + +def _per_bottle_container_names(slug: str) -> list[str]: + """All container names that belong to this bottle. Missing + containers are silently skipped by the teardown helper, so it's + fine to include names that don't exist for a given bottle.""" + return [ + _agent_container_name(slug), + f"claude-bottle-cred-proxy-{slug}", + f"claude-bottle-pipelock-{slug}", + f"claude-bottle-git-gate-{slug}", + f"claude-bottle-supervise-{slug}", + ] + + +def _per_bottle_network_names(slug: str) -> list[str]: + return [ + f"claude-bottle-net-{slug}", + f"claude-bottle-egress-{slug}", + ] + + +class CapabilityApplyError(RuntimeError): + """Raised when the apply fails in a way that should keep the + proposal pending (so the operator can retry). Best-effort + failures (transcript snapshot, git push) do not raise — they + just log and proceed.""" + + +# --- Public helpers -------------------------------------------------------- + + +def fetch_current_dockerfile(slug: str) -> str: + """Return the Dockerfile content the next `cli.py start ` + would use for this bottle. If a per-bottle override exists, that + one; otherwise the repo's Dockerfile. + + Used by the operator-edit verb to show the current source of + truth, and by apply_capability_change for the before-diff.""" + override = per_bottle_dockerfile(slug) + if override is not None: + return override + repo_dockerfile = _repo_dockerfile_path() + if repo_dockerfile.is_file(): + return repo_dockerfile.read_text() + raise CapabilityApplyError( + f"no per-bottle Dockerfile for {slug} and no repo Dockerfile at " + f"{repo_dockerfile}" + ) + + +def apply_capability_change(slug: str, new_dockerfile: str) -> tuple[str, str]: + """End-to-end capability-block remediation. See module docstring + for the sequence. Returns (before, after) Dockerfile content.""" + if not new_dockerfile.strip(): + raise CapabilityApplyError("proposed Dockerfile is empty") + before = fetch_current_dockerfile(slug) + + _snapshot_transcript(slug) + _push_working_tree(slug) + write_per_bottle_dockerfile(slug, new_dockerfile) + _teardown_bottle(slug) + + return before, new_dockerfile + + +# --- Internals ------------------------------------------------------------- + + +def _repo_dockerfile_path() -> Path: + """Path to the repo's Dockerfile (one dir above this module's + package root). Resolved at call time so the path is correct + regardless of where this module is imported from.""" + # claude_bottle/backend/docker/capability_apply.py -> repo root + return Path(__file__).resolve().parent.parent.parent.parent / "Dockerfile" + + +def _snapshot_transcript(slug: str) -> None: + """`docker cp` /home/node/.claude out of the agent container into + ~/.claude-bottle/state//transcript/. Best-effort: missing + container, missing dir, or cp error all log a warning and return. + The transcript is what `claude --resume` reads to pick up where + the agent left off.""" + container = _agent_container_name(slug) + dest = transcript_snapshot_dir(slug) + if dest.exists(): + # Remove any prior snapshot so the new one is a clean copy. + shutil.rmtree(dest, ignore_errors=True) + dest.parent.mkdir(parents=True, exist_ok=True) + r = subprocess.run( + ["docker", "cp", f"{container}:{_AGENT_TRANSCRIPT_IN_CONTAINER}", str(dest)], + capture_output=True, text=True, check=False, + ) + if r.returncode != 0: + warn( + f"capability-apply: transcript snapshot skipped " + f"({(r.stderr or '').strip() or 'no transcript dir in container?'})" + ) + return + info(f"capability-apply: transcript snapshotted to {dest}") + + +def _push_working_tree(slug: str) -> None: + """`docker exec git push` from /home/node/workspace. + Best-effort: not-a-git-repo, no upstream, nothing-to-push, no + network all log a warning and return. The replacement bottle + will pick up whatever's actually upstream.""" + container = _agent_container_name(slug) + r = subprocess.run( + [ + "docker", "exec", container, "sh", "-c", + f"cd {_AGENT_WORKSPACE_IN_CONTAINER} && " + f"git rev-parse --is-inside-work-tree >/dev/null 2>&1 && " + f"git push origin HEAD 2>&1 || true", + ], + capture_output=True, text=True, check=False, + ) + if r.returncode != 0: + warn( + f"capability-apply: git push skipped " + f"({(r.stderr or '').strip() or 'docker exec failed'})" + ) + return + output = (r.stdout or "").strip() + if output: + info(f"capability-apply: git push: {output}") + else: + info("capability-apply: git push ran (no output — likely not a git workspace)") + + +def _teardown_bottle(slug: str) -> None: + """Force-remove all per-bottle docker resources. Idempotent — + `docker rm -f` / `docker network rm` silently ignore missing + names, so this can be called even mid-rebuild.""" + info(f"capability-apply: tearing down bottle {slug}") + for name in _per_bottle_container_names(slug): + subprocess.run( + ["docker", "rm", "-f", name], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False, + ) + for net in _per_bottle_network_names(slug): + subprocess.run( + ["docker", "network", "rm", net], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False, + ) + + +__all__ = [ + "CapabilityApplyError", + "apply_capability_change", + "fetch_current_dockerfile", +] diff --git a/tests/unit/test_capability_apply.py b/tests/unit/test_capability_apply.py new file mode 100644 index 0000000..e3fca9f --- /dev/null +++ b/tests/unit/test_capability_apply.py @@ -0,0 +1,123 @@ +"""Unit: capability_apply helpers (PRD 0016 Phase 2). + +docker cp / exec / rm / network rm paths are covered by the +integration test in Phase 4. Here we cover: + - fetch_current_dockerfile fallback chain (per-bottle → repo) + - apply_capability_change writes the per-bottle Dockerfile and + returns the correct (before, after). + - apply_capability_change rejects empty input. +""" + +import tempfile +import unittest +from pathlib import Path + +from claude_bottle import supervise +from claude_bottle.backend.docker import bottle_state, capability_apply +from claude_bottle.backend.docker.capability_apply import ( + CapabilityApplyError, + apply_capability_change, + fetch_current_dockerfile, +) + + +class _FakeHomeMixin: + def _setup_fake_home(self): + self._tmp = tempfile.TemporaryDirectory(prefix="cap-apply-test.") + original = supervise.claude_bottle_root + + def fake_root() -> Path: + return Path(self._tmp.name) / ".claude-bottle" + + supervise.claude_bottle_root = fake_root # type: ignore[assignment] + self._restore = lambda: setattr(supervise, "claude_bottle_root", original) + + def _teardown_fake_home(self): + self._restore() + self._tmp.cleanup() + + +class TestFetchCurrentDockerfile(_FakeHomeMixin, unittest.TestCase): + def setUp(self): + self._setup_fake_home() + + def tearDown(self): + self._teardown_fake_home() + + def test_returns_per_bottle_dockerfile_when_present(self): + bottle_state.write_per_bottle_dockerfile("dev", "FROM rebuilt\n") + self.assertEqual("FROM rebuilt\n", fetch_current_dockerfile("dev")) + + def test_falls_back_to_repo_dockerfile_when_no_override(self): + # The repo's Dockerfile actually exists; the test just checks + # we get its content (non-empty) when no per-bottle override + # is set. + content = fetch_current_dockerfile("dev-no-override") + self.assertIn("FROM ", content) + + +class TestApplyCapabilityChange(_FakeHomeMixin, unittest.TestCase): + def setUp(self): + self._setup_fake_home() + # Stub out the docker-dependent helpers. The orchestrator's + # job is to sequence write + snapshot + push + teardown; we + # validate that sequence here, not the docker primitives. + self._calls: list[str] = [] + self._orig_snapshot = capability_apply._snapshot_transcript + self._orig_push = capability_apply._push_working_tree + self._orig_teardown = capability_apply._teardown_bottle + + def stub_snapshot(slug): + self._calls.append(f"snapshot:{slug}") + + def stub_push(slug): + self._calls.append(f"push:{slug}") + + def stub_teardown(slug): + self._calls.append(f"teardown:{slug}") + + capability_apply._snapshot_transcript = stub_snapshot # type: ignore[assignment] + capability_apply._push_working_tree = stub_push # type: ignore[assignment] + capability_apply._teardown_bottle = stub_teardown # type: ignore[assignment] + + def tearDown(self): + capability_apply._snapshot_transcript = self._orig_snapshot # type: ignore[assignment] + capability_apply._push_working_tree = self._orig_push # type: ignore[assignment] + capability_apply._teardown_bottle = self._orig_teardown # type: ignore[assignment] + self._teardown_fake_home() + + def test_writes_per_bottle_dockerfile_and_returns_before_after(self): + bottle_state.write_per_bottle_dockerfile("dev", "FROM old\n") + before, after = apply_capability_change("dev", "FROM new\nRUN apk add ripgrep\n") + self.assertEqual("FROM old\n", before) + self.assertEqual("FROM new\nRUN apk add ripgrep\n", after) + self.assertEqual( + "FROM new\nRUN apk add ripgrep\n", + bottle_state.per_bottle_dockerfile("dev"), + ) + + def test_calls_snapshot_push_teardown_in_order(self): + apply_capability_change("dev", "FROM new\n") + # Snapshot + push must happen BEFORE write_per_bottle_dockerfile + # (so they capture pre-rebuild state) and BEFORE teardown (so + # the agent container still exists to docker exec / cp from). + # Teardown must be last. + self.assertEqual( + ["snapshot:dev", "push:dev", "teardown:dev"], + self._calls, + ) + + def test_first_change_falls_back_to_repo_dockerfile_for_before(self): + # No per-bottle override yet — before-diff comes from the + # repo's Dockerfile. + before, after = apply_capability_change("dev-fresh", "FROM new\n") + self.assertIn("FROM ", before) + self.assertEqual("FROM new\n", after) + + def test_empty_dockerfile_rejected(self): + with self.assertRaises(CapabilityApplyError): + apply_capability_change("dev", " \n\t\n") + + +if __name__ == "__main__": + unittest.main()