From d9c47d0fbe00b3b237b4d08fa21acf6a5c39f673 Mon Sep 17 00:00:00 2001 From: didericis Date: Mon, 25 May 2026 05:28:35 -0400 Subject: [PATCH] feat(dashboard): wire capability-block approval to real apply (PRD 0016) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 of PRD 0016. dashboard.approve() now dispatches to apply_capability_change when the proposal is a capability-block: cred-proxy-block → apply_routes_change pipelock-block → apply_allowlist_change capability-block → apply_capability_change (new in PRD 0016) CapabilityApplyError joins the ApplyError tuple, so the TUI's key handlers catch it the same way and surface failures in the status line. After a successful capability-block apply, dashboard archives the proposal+response itself — the supervise sidecar was torn down by apply_capability_change and can't archive its own queue file. Without this, dashboard.discover_pending would keep surfacing the resolved proposal forever. No audit log for capability-block per PRD 0013 — its record lives in the per-bottle Dockerfile state + transcript snapshot. Tests stub apply_capability_change at the dashboard module level, add TestCapabilityApplyWiring (call wiring, failure-keeps-pending, no-audit invariant, archive-after-apply), and update TestApproveReject to stub the capability path too so it stays docker-independent. Co-Authored-By: Claude Opus 4.7 --- claude_bottle/cli/dashboard.py | 20 +++++++-- tests/unit/test_dashboard.py | 75 ++++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 4 deletions(-) diff --git a/claude_bottle/cli/dashboard.py b/claude_bottle/cli/dashboard.py index c379fb6..8528b15 100644 --- a/claude_bottle/cli/dashboard.py +++ b/claude_bottle/cli/dashboard.py @@ -22,6 +22,10 @@ from datetime import datetime, timezone from pathlib import Path from .. import supervise as _supervise +from ..backend.docker.capability_apply import ( + CapabilityApplyError, + apply_capability_change, +) from ..backend.docker.cred_proxy_apply import ( CredProxyApplyError, apply_routes_change, @@ -45,6 +49,7 @@ from ..supervise import ( TOOL_CAPABILITY_BLOCK, TOOL_CRED_PROXY_BLOCK, TOOL_PIPELOCK_BLOCK, + archive_proposal, list_pending_proposals, render_diff, write_audit_entry, @@ -56,7 +61,7 @@ from ._common import PROG # Errors any remediation engine may raise. Caught by the TUI key # handlers and surfaced in the status line so a failed apply keeps # the proposal pending rather than crashing curses. -ApplyError = (CredProxyApplyError, PipelockApplyError) +ApplyError = (CredProxyApplyError, PipelockApplyError, CapabilityApplyError) # --- Discovery ------------------------------------------------------------- @@ -155,9 +160,10 @@ def approve( diff_before, diff_after = apply_allowlist_change( qp.proposal.bottle_slug, file_to_apply, ) - # capability-block remediation lands in PRD 0016; until then - # it stays a no-op approval and the audit (none for capability) - # is skipped. + elif qp.proposal.tool == TOOL_CAPABILITY_BLOCK: + diff_before, diff_after = apply_capability_change( + qp.proposal.bottle_slug, file_to_apply, + ) response = Response( proposal_id=qp.proposal.id, @@ -170,6 +176,12 @@ def approve( qp, action=status, notes=notes, diff_before=diff_before, diff_after=diff_after, ) + if qp.proposal.tool == TOOL_CAPABILITY_BLOCK: + # The supervise sidecar was torn down by apply_capability_change, + # so it can't archive its own proposal+response. Archive here so + # dashboard.discover_pending stops surfacing the resolved + # proposal forever. + archive_proposal(qp.queue_dir, qp.proposal.id) def reject(qp: QueuedProposal, *, reason: str) -> None: diff --git a/tests/unit/test_dashboard.py b/tests/unit/test_dashboard.py index 6e00994..b6f1911 100644 --- a/tests/unit/test_dashboard.py +++ b/tests/unit/test_dashboard.py @@ -16,6 +16,7 @@ from datetime import datetime, timezone from pathlib import Path from claude_bottle import supervise +from claude_bottle.backend.docker.capability_apply import CapabilityApplyError from claude_bottle.backend.docker.cred_proxy_apply import CredProxyApplyError from claude_bottle.backend.docker.pipelock_apply import PipelockApplyError from claude_bottle.cli import dashboard @@ -118,6 +119,7 @@ class TestApproveReject(_FakeHomeMixin, unittest.TestCase): self._setup_fake_home() self._original_apply_routes = dashboard.apply_routes_change self._original_apply_allowlist = dashboard.apply_allowlist_change + self._original_apply_capability = dashboard.apply_capability_change # Default stubs: succeed with deterministic before/after so the # audit log shows a non-empty diff. dashboard.apply_routes_change = lambda slug, content: ( @@ -126,10 +128,14 @@ class TestApproveReject(_FakeHomeMixin, unittest.TestCase): dashboard.apply_allowlist_change = lambda slug, content: ( "old.example\n", content, ) + dashboard.apply_capability_change = lambda slug, content: ( + "FROM old\n", content, + ) def tearDown(self): dashboard.apply_routes_change = self._original_apply_routes dashboard.apply_allowlist_change = self._original_apply_allowlist + dashboard.apply_capability_change = self._original_apply_capability self._teardown_fake_home() def _enqueue(self, tool: str = TOOL_CRED_PROXY_BLOCK): @@ -333,6 +339,75 @@ class TestPipelockApplyWiring(_FakeHomeMixin, unittest.TestCase): self.assertIn("+new.example", entries[0].diff) +class TestCapabilityApplyWiring(_FakeHomeMixin, unittest.TestCase): + """PRD 0016 Phase 3: approve() on a capability-block proposal + calls apply_capability_change, archives the proposal afterward + (sidecar is gone so it can't archive itself), and writes no + audit entry (capability-block has none per PRD 0013).""" + + def setUp(self): + self._setup_fake_home() + self._original = dashboard.apply_capability_change + + def tearDown(self): + dashboard.apply_capability_change = self._original + self._teardown_fake_home() + + def _enqueue_capability(self, proposed: str = "FROM python:3.13\nRUN apk add ripgrep\n"): + p = Proposal.new( + bottle_slug="dev", tool=TOOL_CAPABILITY_BLOCK, + proposed_file=proposed, + justification="need ripgrep", + current_file_hash=sha256_hex(proposed), + now=FIXED, + ) + qdir = supervise.queue_dir_for_slug("dev") + qdir.mkdir(parents=True, exist_ok=True) + supervise.write_proposal(qdir, p) + return dashboard.QueuedProposal(proposal=p, queue_dir=qdir) + + def test_capability_block_calls_apply_with_proposed_file(self): + calls = [] + dashboard.apply_capability_change = lambda slug, content: ( + calls.append((slug, content)) or ("FROM old\n", content) + ) + qp = self._enqueue_capability("FROM bookworm\n") + dashboard.approve(qp) + self.assertEqual([("dev", "FROM bookworm\n")], calls) + + def test_apply_failure_blocks_response_and_keeps_pending(self): + dashboard.apply_capability_change = lambda slug, content: (_ for _ in ()).throw( + CapabilityApplyError("teardown failed") + ) + qp = self._enqueue_capability() + with self.assertRaises(CapabilityApplyError): + dashboard.approve(qp) + self.assertEqual( + [qp.proposal.id], + [p.id for p in supervise.list_pending_proposals(qp.queue_dir)], + ) + + def test_no_audit_log_for_capability(self): + dashboard.apply_capability_change = lambda slug, content: ("FROM old\n", content) + qp = self._enqueue_capability() + dashboard.approve(qp) + # capability-block has no audit log per PRD 0013 — its record + # lives in the per-bottle Dockerfile + transcript state. + self.assertEqual([], read_audit_entries("cred-proxy", "dev")) + self.assertEqual([], read_audit_entries("pipelock", "dev")) + + def test_proposal_archived_after_apply(self): + dashboard.apply_capability_change = lambda slug, content: ("FROM old\n", content) + qp = self._enqueue_capability() + dashboard.approve(qp) + # Sidecar would normally archive after delivering the response, + # but it's gone by then. The dashboard archives so + # discover_pending stops surfacing the resolved proposal. + self.assertEqual([], supervise.list_pending_proposals(qp.queue_dir)) + processed = list((qp.queue_dir / "processed").glob("*.json")) + self.assertEqual(2, len(processed)) + + class TestOperatorEditRoutes(_FakeHomeMixin, unittest.TestCase): """PRD 0014 Phase 4: operator-initiated routes edit (not gated on a pending proposal)."""