fix(supervise): reach the queue over RPC, get bot-bottle.db off the data plane

PRD 0070's rule — only the orchestrator opens bot-bottle.db; the data
plane reaches state through the control-plane RPC — was not in force.
Three data-plane daemons held a direct read-write handle on the shared
SQLite file: the supervise MCP server, the egress DLP addon (the most
attack-exposed process, TLS-bumping hostile traffic), and the git-gate
pre-receive hook. An RCE in any of them could read every bottle's
plaintext identity_token and forge attribution fleet-wide (issue #469).

Add the agent half of the supervise flow to the control plane:

  POST /supervise/propose  -> queue a proposal, 201 {proposal_id}
  POST /supervise/poll     -> non-blocking decision poll, 200 {status,...}

Both attribute the caller by (source_ip, identity_token) exactly like
/resolve — never a caller-supplied slug — so a bottle can only ever queue
or read its own proposals even if the data plane is compromised. A decided
poll archives server-side, preserving the archive-after-read contract.

Data plane: the supervise server, egress addon, and git-gate hook now
queue/poll through PolicyResolver.propose_supervise / poll_supervise
instead of opening the DB. supervise_server keeps its ~30s grace window
by polling the RPC; egress keeps its safelist keyed by resolved bottle;
the git-gate hook gets (source_ip, identity_token) from the CGI env.

Packaging: drop the DB bind-mount and SUPERVISE_DB_PATH from the
data-plane containers/VMs (docker gateway + infra, macOS infra,
firecracker infra). The orchestrator remains the sole opener of the one
file via BOT_BOTTLE_ROOT / host_db_path().

Update PRD 0070: the rule is now in force; remove the transitional caveat.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-24 02:07:21 +00:00
parent f24ae45d13
commit 2c496dc3d0
23 changed files with 968 additions and 518 deletions
+70 -73
View File
@@ -209,6 +209,7 @@ from bot_bottle.egress_addon_core import ( # noqa: E402
Route,
route_to_yaml_dict,
)
from bot_bottle.policy_resolver import PolicyResolveError # noqa: E402
# ---------------------------------------------------------------------------
@@ -266,7 +267,42 @@ def _config_to_policy(config: Config) -> str:
}) + "\n"
class _StaticResolver:
class _SuperviseRpcFake:
"""Mixin adding the control-plane supervise RPCs to a fake resolver, now
that the egress data plane queues/polls proposals over RPC instead of
opening the DB (issue #469). `supervise_status` is the operator's eventual
decision (None models a timeout — poll stays `pending`); `propose_error`
models an unreachable orchestrator. `propose_calls` records what was queued
so a test can assert the proposal was attributed by the caller's source IP."""
supervise_status: "str | None" = None
propose_error: bool = False
@property
def propose_calls(self) -> list:
if not hasattr(self, "_propose_calls"):
self._propose_calls: list = []
return self._propose_calls
def propose_supervise(
self, source_ip, identity_token, *, tool, proposed_file, justification,
):
del proposed_file, justification
self.propose_calls.append(
{"source_ip": source_ip, "identity_token": identity_token, "tool": tool}
)
if self.propose_error:
raise PolicyResolveError("orchestrator down")
return "prop-1"
def poll_supervise(self, source_ip, identity_token, proposal_id):
del source_ip, identity_token, proposal_id
if self.supervise_status is None:
return {"status": "pending"}
return {"status": self.supervise_status, "notes": "", "final_file": None}
class _StaticResolver(_SuperviseRpcFake):
"""Fake orchestrator resolver that serves one Config (+ optional bottle id
and per-bottle tokens) for every client — the host-test stand-in for a
bottle's policy now that egress is resolver-only."""
@@ -323,7 +359,7 @@ def _with_client_ip(flow: _Flow, ip: str) -> _Flow:
return flow
class _CtxResolver:
class _CtxResolver(_SuperviseRpcFake):
"""Fake orchestrator resolver: maps source IP -> bottle id, and grants the
same allow-list to any attributed bottle (unattributed -> deny)."""
@@ -516,66 +552,33 @@ class TestOutboundDlpPolicy(unittest.TestCase):
# ---------------------------------------------------------------------------
def _fake_sv(response_status: str | None) -> types.SimpleNamespace:
"""Stand-in for the `supervise` module the adapter queues proposals to.
`response_status` of None models a timeout (read_response never returns a
decision); a status string models the operator's eventual answer."""
def _new_proposal(**_kw: Any) -> Any:
return types.SimpleNamespace(id="prop-1")
def _sha256_hex(_payload: Any) -> str:
return "hash"
def _noop(*_args: Any) -> None:
return None
def _read_response(_slug: Any, _pid: Any) -> Any:
if response_status is None:
raise OSError("not written yet") # forces poll -> timeout
return types.SimpleNamespace(status=response_status)
ns = types.SimpleNamespace()
ns.STATUS_APPROVED = "approved"
ns.STATUS_MODIFIED = "modified"
ns.TOOL_EGRESS_TOKEN_ALLOW = "egress_token_allow"
ns.Proposal = types.SimpleNamespace(new=_new_proposal)
ns.sha256_hex = _sha256_hex
ns.write_proposal = _noop
ns.archive_proposal = _noop
ns.read_response = _read_response
return ns
class TestSuperviseBranch(unittest.TestCase):
def _supervised_addon(self) -> EgressAddon:
def _supervised_addon(self, status: str | None) -> EgressAddon:
addon = _addon(Config(routes=(Route(host="api.example.com"),)), slug="test-bottle")
addon._token_allow_timeout = 0.05
cast(Any, addon._resolver).supervise_status = status
return addon
def test_operator_approval_allows_token_and_forwards(self) -> None:
addon = self._supervised_addon()
addon = self._supervised_addon("approved")
flow = _Flow(_Request(host="api.example.com", method="POST", body=f"k={_OPENAI_KEY}"))
with patch.object(_ea_mod, "_sv", _fake_sv("approved")):
_run_request(addon, flow)
_run_request(addon, flow)
self.assertIsNone(flow.response) # forwarded after approval
# Approval lands in the calling bottle's safelist (keyed by slug).
self.assertIn(_OPENAI_KEY, addon._safe_tokens_for("test-bottle"))
def test_operator_rejection_blocks(self) -> None:
addon = self._supervised_addon()
addon = self._supervised_addon("rejected")
flow = _Flow(_Request(host="api.example.com", method="POST", body=f"k={_OPENAI_KEY}"))
with patch.object(_ea_mod, "_sv", _fake_sv("rejected")):
_run_request(addon, flow)
_run_request(addon, flow)
assert flow.response is not None
self.assertEqual(403, flow.response.status_code)
self.assertIn("rejected", flow.response.get_text())
def test_supervise_timeout_blocks(self) -> None:
addon = self._supervised_addon()
addon = self._supervised_addon(None) # poll stays pending -> timeout
flow = _Flow(_Request(host="api.example.com", method="POST", body=f"k={_OPENAI_KEY}"))
with patch.object(_ea_mod, "_sv", _fake_sv(None)):
_run_request(addon, flow)
_run_request(addon, flow)
assert flow.response is not None
self.assertEqual(403, flow.response.status_code)
self.assertIn("timed out", flow.response.get_text())
@@ -772,19 +775,14 @@ class TestRedactSurfaces(unittest.TestCase):
class TestSuperviseWriteFailure(unittest.TestCase):
def test_write_proposal_oserror_blocks(self) -> None:
def test_propose_rpc_error_blocks(self) -> None:
# An unreachable orchestrator (propose RPC raises) fails closed: the
# request is blocked rather than forwarded unsupervised.
addon = _addon(Config(routes=(Route(host="api.example.com"),)), slug="test-bottle")
addon._token_allow_timeout = 0.05
cast(Any, addon._resolver).propose_error = True
flow = _Flow(_Request(host="api.example.com", method="POST", body=f"k={_OPENAI_KEY}"))
fake = _fake_sv("approved")
def _raise(_p: Any) -> None:
raise OSError("disk full")
fake.write_proposal = _raise
with patch.object(_ea_mod, "_sv", fake):
_run_request(addon, flow)
_run_request(addon, flow)
assert flow.response is not None
self.assertEqual(403, flow.response.status_code)
@@ -844,22 +842,23 @@ class TestSuperviseMultiTenant(unittest.TestCase):
"""Consolidated gateway: supervise proposals + the DLP safelist are keyed
per bottle, resolved by source IP (PRD 0070)."""
def _consolidated_addon(self) -> EgressAddon:
def _consolidated_addon(self, status: str | None = "approved") -> EgressAddon:
# Static config is empty; the resolver supplies each bottle's config.
addon = _addon(Config(routes=()))
addon._resolver = cast(Any, _CtxResolver({"10.0.0.1": "bottle-a", "10.0.0.2": "bottle-b"}))
resolver = _CtxResolver({"10.0.0.1": "bottle-a", "10.0.0.2": "bottle-b"})
resolver.supervise_status = status
addon._resolver = cast(Any, resolver)
addon._token_allow_timeout = 0.05
return addon
def test_approval_is_scoped_to_the_calling_bottle(self) -> None:
addon = self._consolidated_addon()
addon = self._consolidated_addon("approved")
# bottle-a (10.0.0.1) sends the token; the operator approves.
flow = _with_client_ip(
_Flow(_Request(host="api.example.com", method="POST", body=f"k={_OPENAI_KEY}")),
"10.0.0.1",
)
with patch.object(_ea_mod, "_sv", _fake_sv("approved")):
_run_request(addon, flow)
_run_request(addon, flow)
self.assertIsNone(flow.response) # forwarded after approval
# The approval lands ONLY in bottle-a's safelist — never bottle-b's.
# A global set here would be the cross-tenant leak this slice closes.
@@ -867,22 +866,19 @@ class TestSuperviseMultiTenant(unittest.TestCase):
self.assertNotIn(_OPENAI_KEY, addon._safe_tokens_for("bottle-b"))
def test_proposal_is_attributed_to_the_source_ip_bottle(self) -> None:
addon = self._consolidated_addon()
seen: list[str] = []
fake = _fake_sv("approved")
def _capture(**kw: Any) -> Any:
seen.append(kw["bottle_slug"])
return types.SimpleNamespace(id="p")
fake.Proposal = types.SimpleNamespace(new=_capture)
addon = self._consolidated_addon("approved")
flow = _with_client_ip(
_Flow(_Request(host="api.example.com", method="POST", body=f"k={_OPENAI_KEY}")),
"10.0.0.2",
)
with patch.object(_ea_mod, "_sv", fake):
_run_request(addon, flow)
self.assertEqual(["bottle-b"], seen) # proposal keyed by the resolved bottle
_run_request(addon, flow)
# The addon forwards the caller's source IP to the control plane, which
# attributes the proposal server-side by (source_ip, identity_token) —
# the addon never asserts a slug. The resolved bottle keys the safelist.
calls = cast(Any, addon._resolver).propose_calls
self.assertEqual(["10.0.0.2"], [c["source_ip"] for c in calls])
self.assertIn(_OPENAI_KEY, addon._safe_tokens_for("bottle-b"))
self.assertNotIn(_OPENAI_KEY, addon._safe_tokens_for("bottle-a"))
def test_auth_token_injected_from_resolved_tokens(self) -> None:
# The bottle's upstream token comes from /resolve (in-memory on the
@@ -925,16 +921,17 @@ class TestSuperviseMultiTenant(unittest.TestCase):
self.assertIsNotNone(flow.response) # blocked — token unset
def test_unattributed_source_ip_cannot_supervise(self) -> None:
addon = self._consolidated_addon()
addon = self._consolidated_addon("approved")
# 10.9.9.9 is not in the resolver map -> deny-all config, empty slug.
flow = _with_client_ip(
_Flow(_Request(host="api.example.com", method="POST", body=f"k={_OPENAI_KEY}")),
"10.9.9.9",
)
with patch.object(_ea_mod, "_sv", _fake_sv("approved")):
_run_request(addon, flow)
_run_request(addon, flow)
self.assertIsNotNone(flow.response) # blocked (no route, no supervise)
self.assertNotIn(_OPENAI_KEY, addon._safe_tokens_for(""))
# Never even reached the queue — no route means no supervise proposal.
self.assertEqual([], cast(Any, addon._resolver).propose_calls)
class TestMultiTenantInboundDlp(unittest.TestCase):
+13 -10
View File
@@ -213,22 +213,25 @@ class TestHookRender(unittest.TestCase):
# the suppressed findings for human approval.
self.assertIn("--ignore-gitleaks-allow", hook)
self.assertIn("--report-format=json", hook)
self.assertIn("tool=_sv.TOOL_GITLEAKS_ALLOW", hook)
self.assertIn("_sv.write_proposal", hook)
self.assertIn("_sv.read_response", hook)
self.assertIn("SUPERVISE_BOTTLE_SLUG", hook)
# The hook queues + polls over the control-plane RPC — it no longer
# opens the DB directly (PRD 0070 / issue #469).
self.assertIn("tool=TOOL_GITLEAKS_ALLOW", hook)
self.assertIn("propose_supervise", hook)
self.assertIn("poll_supervise", hook)
self.assertIn("SUPERVISE_SOURCE_IP", hook)
self.assertIn("SUPERVISE_IDENTITY_TOKEN", hook)
self.assertIn("supervisor approved # gitleaks:allow", hook)
self.assertIn("supervisor rejected # gitleaks:allow", hook)
def test_inline_gitleaks_allow_python_imports_work_in_gateway_layout(self):
hook = git_gate_render_hook()
# The gateway image copies supervise.py flat under /app, while
# host-side tests import it through the bot_bottle package.
# Hooks execute from the bare repo directory, so the embedded
# Python must include /app and support both import layouts.
# The gateway image copies the package modules flat under /app, while
# host-side tests import them through the bot_bottle package. Hooks
# execute from the bare repo directory, so the embedded Python must
# include /app and support both import layouts.
self.assertIn('PYTHONPATH="/app${PYTHONPATH:+:$PYTHONPATH}"', hook)
self.assertIn("import supervise as _sv", hook)
self.assertIn("from bot_bottle import supervise as _sv", hook)
self.assertIn("from bot_bottle.policy_resolver import PolicyResolver", hook)
self.assertIn("from policy_resolver import PolicyResolver", hook)
def test_inline_gitleaks_allow_fails_closed_without_supervisor(self):
hook = git_gate_render_hook()
+10 -8
View File
@@ -112,11 +112,13 @@ class TestGitHttpBackend(unittest.TestCase):
).strip()
self.assertEqual(head, cloned)
def test_consolidated_push_stamps_bottle_slug_for_the_hook(self):
# In consolidated mode the backend attributes the push by source IP and
# stamps SUPERVISE_BOTTLE_SLUG=<bottle_id> into the CGI env, so the
# gitleaks-allow pre-receive hook queues its proposal under the right
# bottle. The hook here just records what it received.
def test_consolidated_push_stamps_supervise_attribution_for_the_hook(self):
# In consolidated mode the backend attributes the push by (source IP,
# identity token) and stamps SUPERVISE_SOURCE_IP + SUPERVISE_IDENTITY_TOKEN
# into the CGI env, so the gitleaks-allow pre-receive hook can queue its
# proposal over the control-plane RPC (which re-resolves the bottle from
# exactly that pair — PRD 0070 / issue #469). The hook here just records
# the source IP it received.
from http.server import ThreadingHTTPServer
bottle_id = "bottleab12"
@@ -130,10 +132,10 @@ class TestGitHttpBackend(unittest.TestCase):
["git", "-C", str(bare), "config", "http.receivepack", "true"],
check=True,
)
capture = root / "slug-capture"
capture = root / "source-ip-capture"
hook = bare / "hooks" / "pre-receive"
hook.write_text(
f"#!/bin/sh\nprintf '%s' \"${{SUPERVISE_BOTTLE_SLUG:-UNSET}}\" > "
f"#!/bin/sh\nprintf '%s' \"${{SUPERVISE_SOURCE_IP:-UNSET}}\" > "
f"{capture}\ncat >/dev/null\nexit 0\n"
)
hook.chmod(0o755)
@@ -166,7 +168,7 @@ class TestGitHttpBackend(unittest.TestCase):
["git", "push", url, "HEAD:refs/heads/main"],
cwd=work, check=True, capture_output=True, text=True, timeout=5,
)
self.assertEqual(bottle_id, capture.read_text())
self.assertEqual("127.0.0.1", capture.read_text())
def test_post_forwards_git_cgi_headers(self):
from http.server import ThreadingHTTPServer
@@ -426,6 +426,112 @@ class TestDispatchSupervise(unittest.TestCase):
self.assertIn("no such proposal", str(payload["error"]))
class TestDispatchSuperviseAgentRpc(unittest.TestCase):
"""The agent half — `/supervise/propose` + `/supervise/poll` — attributed by
(source_ip, identity_token) like /resolve, so a bottle can only ever queue
or read its own proposals (PRD 0070 / issue #469)."""
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory()
root = Path(self._tmp.name)
db = root / "db" / "bot-bottle.db"
db.parent.mkdir(parents=True)
self._env = patch.dict("os.environ", {
"BOT_BOTTLE_ROOT": str(root),
"SUPERVISE_DB_PATH": str(db),
})
self._env.start()
self.store = RegistryStore(db)
self.store.migrate()
StoreManager(db).migrate()
secret = secrets.token_bytes(16)
self.orch = Orchestrator(self.store, StubBroker(secret), secret)
def tearDown(self) -> None:
self._env.stop()
self._tmp.cleanup()
def _register(self, source_ip: str = "10.243.0.9", slug: str = "demo"):
return self.store.register(
source_ip, metadata=json.dumps({"slug": slug}), policy="routes: []\n")
def _propose(self, rec, proposed: str = "routes:\n - host: g.com\n"):
return dispatch(self.orch, "POST", "/supervise/propose", _body({
"source_ip": rec.source_ip, "identity_token": rec.identity_token,
"tool": TOOL_EGRESS_ALLOW, "proposed_file": proposed, "justification": "need it",
}))
def _poll(self, rec, proposal_id: str):
return dispatch(self.orch, "POST", "/supervise/poll", _body({
"source_ip": rec.source_ip, "identity_token": rec.identity_token,
"proposal_id": proposal_id,
}))
def test_propose_queues_under_the_resolved_bottle(self) -> None:
rec = self._register()
status, payload = self._propose(rec)
self.assertEqual(201, status)
pid = payload["proposal_id"]
assert isinstance(pid, str) and pid
_, listing = dispatch(self.orch, "GET", "/supervise/proposals", b"")
self.assertEqual(pid, listing["proposals"][0]["id"])
# Queued under the orchestrator-resolved bottle id, never a caller slug.
self.assertEqual(rec.bottle_id, listing["proposals"][0]["bottle_slug"])
def test_propose_unattributed_is_403(self) -> None:
status, payload = dispatch(self.orch, "POST", "/supervise/propose", _body({
"source_ip": "10.9.9.9", "identity_token": "wrong",
"tool": TOOL_EGRESS_ALLOW, "proposed_file": "x\n", "justification": "j",
}))
self.assertEqual(403, status)
self.assertIn("unattributed", str(payload["error"]))
def test_propose_rejects_unknown_tool(self) -> None:
rec = self._register()
status, _ = dispatch(self.orch, "POST", "/supervise/propose", _body({
"source_ip": rec.source_ip, "identity_token": rec.identity_token,
"tool": "not-a-tool", "proposed_file": "x\n", "justification": "j",
}))
self.assertEqual(400, status)
def test_poll_pending_then_decided_then_archived(self) -> None:
rec = self._register()
_, proposed = self._propose(rec)
pid = proposed["proposal_id"]
assert isinstance(pid, str)
status, poll = self._poll(rec, pid)
self.assertEqual(200, status)
self.assertEqual("pending", poll["status"])
# Operator decides server-side.
dispatch(self.orch, "POST", "/supervise/respond", _body({
"proposal_id": pid, "bottle_slug": rec.bottle_id,
"decision": "approve", "notes": "ok",
}))
_, decided = self._poll(rec, pid)
self.assertEqual("approved", decided["status"])
self.assertEqual("ok", decided["notes"])
# The decided poll archived it: gone from pending, and a re-poll is
# 'unknown' rather than replaying the decision forever.
_, listing = dispatch(self.orch, "GET", "/supervise/proposals", b"")
self.assertEqual([], listing["proposals"])
_, again = self._poll(rec, pid)
self.assertEqual("unknown", again["status"])
def test_poll_cannot_read_another_bottles_proposal(self) -> None:
rec_a = self._register("10.0.0.1", "a")
rec_b = self._register("10.0.0.2", "b")
_, proposed = self._propose(rec_a)
pid = proposed["proposal_id"]
assert isinstance(pid, str)
# b polls a's proposal id: scoped to b's own queue → never a's response.
status, poll = self._poll(rec_b, pid)
self.assertEqual(200, status)
self.assertEqual("unknown", poll["status"])
if __name__ == "__main__":
unittest.main()
+5 -7
View File
@@ -124,13 +124,11 @@ class TestDockerGateway(unittest.TestCase):
src = ca_mounts[0].rsplit(":", 1)[0]
self.assertTrue(src.endswith("/" + GATEWAY_CA_DIRNAME), src)
self.assertTrue(Path(src).is_absolute(), src)
# Shares the ONE host DB: the supervise daemon queues into the same
# file the orchestrator + operator (over HTTP) use.
self.assertTrue(any(
a.startswith("SUPERVISE_DB_PATH=") and a.endswith("/run/supervise/bot-bottle.db")
for a in runs[0]))
self.assertTrue(any(
a.endswith(":/run/supervise") for a in runs[0]))
# No DB handle on the data plane: the supervise queue is reached over
# the control-plane RPC, so the gateway container carries neither the
# DB bind-mount nor SUPERVISE_DB_PATH (PRD 0070 / issue #469).
self.assertFalse(any(a.startswith("SUPERVISE_DB_PATH=") for a in runs[0]))
self.assertFalse(any(a.endswith(":/run/supervise") for a in runs[0]))
# Data plane resolves policy against the orchestrator control plane.
self.assertIn(f"BOT_BOTTLE_ORCHESTRATOR_URL={_ORCH_URL}", runs[0])
+37
View File
@@ -323,6 +323,43 @@ class TestOrchestratorSupervise(unittest.TestCase):
self.assertFalse(ok)
self.assertIn("no longer registered", err)
# --- agent half: queue + poll (issue #469) -----------------------------
def test_queue_proposal_then_poll_pending(self) -> None:
bottle_id = self._register("demo", "routes: []\n")
pid = self.orch.supervise_queue_proposal(
bottle_id, tool=TOOL_EGRESS_ALLOW,
proposed_file="routes:\n - host: google.com\n", justification="need it")
# Visible to the operator, keyed by the bottle id.
pending = self.orch.supervise_pending()
self.assertEqual([pid], [p["id"] for p in pending])
self.assertEqual(
{"status": "pending"}, self.orch.supervise_poll_response(bottle_id, pid))
def test_poll_returns_decision_and_archives(self) -> None:
bottle_id = self._register("demo", "routes: []\n")
pid = self.orch.supervise_queue_proposal(
bottle_id, tool=TOOL_EGRESS_ALLOW,
proposed_file="routes:\n - host: google.com\n", justification="need it")
self.orch.supervise_respond(
pid, bottle_slug=bottle_id, decision="approve", notes="ok")
decided = self.orch.supervise_poll_response(bottle_id, pid)
self.assertEqual("approved", decided["status"])
self.assertEqual("ok", decided["notes"])
# Archived on read → gone from pending, and a re-poll is 'unknown'.
self.assertEqual([], self.orch.supervise_pending())
self.assertEqual(
{"status": "unknown"}, self.orch.supervise_poll_response(bottle_id, pid))
def test_poll_unknown_for_other_bottle(self) -> None:
bottle_id = self._register("demo", "routes: []\n")
pid = self.orch.supervise_queue_proposal(
bottle_id, tool=TOOL_EGRESS_ALLOW,
proposed_file="routes:\n - host: google.com\n", justification="j")
# A different bottle id can't read demo's proposal (scoped by queue key).
self.assertEqual(
{"status": "unknown"}, self.orch.supervise_poll_response("other-bottle", pid))
if __name__ == "__main__":
unittest.main()
+52
View File
@@ -106,6 +106,58 @@ class TestPolicyResolver(unittest.TestCase):
with self.assertRaises(PolicyResolveError):
self.r.resolve_policy_and_bottle_id("10.243.0.1")
# --- supervise agent RPCs (issue #469) ---------------------------------
def test_propose_supervise_returns_id_and_posts_payload(self) -> None:
with patch(_URLOPEN, return_value=_resp({"proposal_id": "p-7"})) as m:
pid = self.r.propose_supervise(
"10.243.0.7", "the-token",
tool="egress-allow", proposed_file="routes:\n", justification="j",
)
self.assertEqual("p-7", pid)
req = m.call_args.args[0]
self.assertTrue(req.full_url.endswith("/supervise/propose"))
sent = json.loads(req.data)
self.assertEqual("10.243.0.7", sent["source_ip"])
self.assertEqual("the-token", sent["identity_token"])
self.assertEqual("egress-allow", sent["tool"])
self.assertEqual("routes:\n", sent["proposed_file"])
def test_propose_supervise_unattributed_is_none(self) -> None:
with patch(_URLOPEN, side_effect=_http_error(403)):
self.assertIsNone(self.r.propose_supervise(
"10.9.9.9", "t", tool="egress-allow", proposed_file="x", justification="j"))
def test_propose_supervise_missing_id_is_none(self) -> None:
with patch(_URLOPEN, return_value=_resp({})):
self.assertIsNone(self.r.propose_supervise(
"10.243.0.1", "t", tool="egress-allow", proposed_file="x", justification="j"))
def test_propose_supervise_unreachable_raises(self) -> None:
with patch(_URLOPEN, side_effect=urllib.error.URLError("refused")):
with self.assertRaises(PolicyResolveError):
self.r.propose_supervise(
"10.243.0.1", "t", tool="egress-allow", proposed_file="x", justification="j")
def test_poll_supervise_returns_status(self) -> None:
with patch(_URLOPEN, return_value=_resp(
{"status": "approved", "notes": "ok", "final_file": None})
) as m:
result = self.r.poll_supervise("10.243.0.7", "tok", "p-7")
self.assertEqual("approved", result["status"])
req = m.call_args.args[0]
self.assertTrue(req.full_url.endswith("/supervise/poll"))
self.assertEqual("p-7", json.loads(req.data)["proposal_id"])
def test_poll_supervise_unattributed_is_none(self) -> None:
with patch(_URLOPEN, side_effect=_http_error(403)):
self.assertIsNone(self.r.poll_supervise("10.9.9.9", "t", "p-7"))
def test_poll_supervise_unreachable_raises(self) -> None:
with patch(_URLOPEN, side_effect=urllib.error.URLError("refused")):
with self.assertRaises(PolicyResolveError):
self.r.poll_supervise("10.243.0.1", "t", "p-7")
if __name__ == "__main__":
unittest.main()
+188 -189
View File
@@ -1,4 +1,11 @@
"""Unit: supervise daemon MCP server (PRD 0013)."""
"""Unit: supervise daemon MCP server (PRD 0013, PRD 0070).
The daemon no longer opens bot-bottle.db: it queues proposals and polls for
their responses over the control-plane RPC (issue #469). These tests drive the
handlers with `_FakeSuperviseResolver`, an in-process stand-in for
`PolicyResolver.propose_supervise` / `poll_supervise` backed by the real queue
store so the operator-response and archive contracts are still exercised
end-to-end, just through the RPC seam instead of a direct file handle."""
import http.client
import json
@@ -44,6 +51,81 @@ from bot_bottle.supervise_server import (
validate_proposed_file,
)
# Fixed caller identity for the handler tests. The control plane attributes by
# (source_ip, identity_token); the fake resolver ignores them and answers for a
# fixed bottle, since attribution itself is covered by the orchestrator tests.
_SRC = "10.0.0.7"
_TOK = "tok"
class _FakeSuperviseResolver:
"""Stand-in for `PolicyResolver`'s supervise RPCs, backed by the real queue
store (as the orchestrator is). `bottle_id=None` models an unattributed
caller (a clean 403 None); `raises=True` models an unreachable
orchestrator (`PolicyResolveError`)."""
def __init__(
self, bottle_id: str | None = "dev", raises: bool = False, policy: str = "",
) -> None:
self.bottle_id = bottle_id
self.raises = raises
self._policy = policy
def propose_supervise(
self, source_ip, identity_token, *, tool, proposed_file, justification,
):
del source_ip, identity_token
if self.raises:
raise supervise_server.PolicyResolveError("orchestrator down")
if self.bottle_id is None:
return None
proposal = _sv.Proposal.new(
bottle_slug=self.bottle_id, tool=tool, proposed_file=proposed_file,
justification=justification, current_file_hash=_sv.sha256_hex(proposed_file),
)
_sv.write_proposal(proposal)
return proposal.id
def poll_supervise(self, source_ip, identity_token, proposal_id):
del source_ip, identity_token
if self.raises:
raise supervise_server.PolicyResolveError("orchestrator down")
if self.bottle_id is None:
return None
try:
response = _sv.read_response(self.bottle_id, proposal_id)
except FileNotFoundError:
try:
_sv.read_proposal(self.bottle_id, proposal_id)
except FileNotFoundError:
return {"status": _sv.POLL_STATUS_UNKNOWN}
return {"status": _sv.POLL_STATUS_PENDING}
_sv.archive_proposal(self.bottle_id, proposal_id)
return {
"status": response.status, "notes": response.notes,
"final_file": response.final_file,
}
# Used by list-egress-routes (`_resolved_routes_payload`), unchanged path.
def resolve_policy_and_bottle_id(self, source_ip, identity_token=""):
del source_ip, identity_token
if self.raises:
raise supervise_server.PolicyResolveError("orchestrator down")
return self._policy, self.bottle_id, {}
def _tools_call(resolver, params, config=None):
return handle_tools_call(
params, config or ServerConfig(),
resolver=resolver, source_ip=_SRC, identity_token=_TOK,
)
def _check(resolver, params):
return handle_check_proposal(
params, resolver=resolver, source_ip=_SRC, identity_token=_TOK,
)
# --- Validation ------------------------------------------------------------
@@ -111,32 +193,35 @@ class TestRpcErrorTaxonomy(unittest.TestCase):
validate_proposed_file(_sv.TOOL_EGRESS_ALLOW, "routes: nope\n")
def test_unknown_tool_in_tools_call_is_client_error(self):
config = ServerConfig(bottle_slug="dev")
with self.assertRaises(_RpcClientError) as cm:
handle_tools_call({"name": "no-such-tool", "arguments": {}}, config)
_tools_call(_FakeSuperviseResolver(), {"name": "no-such-tool", "arguments": {}})
self.assertEqual(ERR_INVALID_PARAMS, cm.exception.code)
class TestRpcInternalErrorOnIoFailure(unittest.TestCase):
def test_write_proposal_os_error_raises_internal(self):
config = ServerConfig(
bottle_slug="dev",
)
with patch.object(_sv, "write_proposal", side_effect=OSError("disk full")), \
self.assertRaises(_RpcInternalError) as cm:
handle_tools_call(
{
"name": _sv.TOOL_EGRESS_ALLOW,
"arguments": {
"routes_yaml": "routes:\n - host: example.com\n",
"justification": "x",
},
},
config,
)
class TestRpcInternalErrorOnRpcFailure(unittest.TestCase):
"""A queue RPC that can't reach the orchestrator (or returns unattributed)
surfaces as ERR_INTERNAL the daemon fails closed rather than leaking the
cause to the agent."""
_ARGS = {
"name": _sv.TOOL_EGRESS_ALLOW,
"arguments": {
"routes_yaml": "routes:\n - host: example.com\n",
"justification": "x",
},
}
def test_unreachable_orchestrator_raises_internal(self):
with self.assertRaises(_RpcInternalError) as cm:
_tools_call(_FakeSuperviseResolver(raises=True), self._ARGS)
self.assertEqual(ERR_INTERNAL, cm.exception.code)
self.assertIsNotNone(cm.exception.__cause__)
def test_unattributed_source_raises_internal(self):
with self.assertRaises(_RpcInternalError) as cm:
_tools_call(_FakeSuperviseResolver(bottle_id=None), self._ARGS)
self.assertEqual(ERR_INTERNAL, cm.exception.code)
# --- JSON-RPC parsing ------------------------------------------------------
@@ -265,8 +350,8 @@ class TestHandleToolsList(unittest.TestCase):
class TestHandleToolsCall(unittest.TestCase):
def setUp(self):
self._tmp = tempfile.TemporaryDirectory(prefix="supervise-server-test.")
self._home_patch = self._patch_home(Path(self._tmp.name))
self.config = ServerConfig(bottle_slug="dev")
self._home_patch = use_bottle_root(Path(self._tmp.name) / ".bot-bottle")
self.resolver = _FakeSuperviseResolver("dev")
_qs.QueueStore("dev").migrate()
_as.AuditStore().migrate()
@@ -274,12 +359,9 @@ class TestHandleToolsCall(unittest.TestCase):
self._home_patch()
self._tmp.cleanup()
def _patch_home(self, fake_home: Path):
return use_bottle_root(fake_home / ".bot-bottle")
def _respond_when_proposal_appears(self, status: str, notes: str = "") -> threading.Thread:
"""Background thread: poll the queue for a fresh proposal, write a
matching response. Returns the thread so the test can join it."""
matching response the operator half, out of band."""
def runner():
for _ in range(200):
pending = _sv.list_pending_proposals("dev")
@@ -298,16 +380,13 @@ class TestHandleToolsCall(unittest.TestCase):
def test_call_round_trips_through_queue(self):
responder = self._respond_when_proposal_appears(_sv.STATUS_APPROVED, notes="lgtm")
try:
result = handle_tools_call(
{
"name": _sv.TOOL_EGRESS_BLOCK,
"arguments": {
"routes_yaml": "routes:\n - host: example.com\n",
"justification": "need example.com",
},
result = _tools_call(self.resolver, {
"name": _sv.TOOL_EGRESS_BLOCK,
"arguments": {
"routes_yaml": "routes:\n - host: example.com\n",
"justification": "need example.com",
},
self.config,
)
})
finally:
responder.join()
self.assertFalse(result["isError"]) # type: ignore[index]
@@ -318,16 +397,13 @@ class TestHandleToolsCall(unittest.TestCase):
def test_allow_round_trips_through_queue(self):
responder = self._respond_when_proposal_appears(_sv.STATUS_APPROVED, notes="ok")
try:
result = handle_tools_call(
{
"name": _sv.TOOL_EGRESS_ALLOW,
"arguments": {
"routes_yaml": "routes:\n - host: example.com\n",
"justification": "need example.com",
},
result = _tools_call(self.resolver, {
"name": _sv.TOOL_EGRESS_ALLOW,
"arguments": {
"routes_yaml": "routes:\n - host: example.com\n",
"justification": "need example.com",
},
self.config,
)
})
finally:
responder.join()
self.assertFalse(result["isError"]) # type: ignore[index]
@@ -338,94 +414,70 @@ class TestHandleToolsCall(unittest.TestCase):
def test_rejected_response_sets_isError(self):
responder = self._respond_when_proposal_appears(_sv.STATUS_REJECTED, notes="nope")
try:
result = handle_tools_call(
{
"name": _sv.TOOL_EGRESS_ALLOW,
"arguments": {
"routes_yaml": "routes:\n - host: example.com\n",
"justification": "needed for tests",
},
result = _tools_call(self.resolver, {
"name": _sv.TOOL_EGRESS_ALLOW,
"arguments": {
"routes_yaml": "routes:\n - host: example.com\n",
"justification": "needed for tests",
},
self.config,
)
})
finally:
responder.join()
self.assertTrue(result["isError"]) # type: ignore[index]
def test_invalid_tool_name_raises(self):
with self.assertRaises(_RpcError) as cm:
handle_tools_call(
{"name": "not-a-tool", "arguments": {}},
self.config,
)
_tools_call(self.resolver, {"name": "not-a-tool", "arguments": {}})
self.assertEqual(ERR_INVALID_PARAMS, cm.exception.code)
def test_missing_justification_raises(self):
with self.assertRaises(_RpcError):
handle_tools_call(
{
"name": _sv.TOOL_EGRESS_ALLOW,
"arguments": {"routes_yaml": "routes:\n - host: example.com\n"},
},
self.config,
)
_tools_call(self.resolver, {
"name": _sv.TOOL_EGRESS_ALLOW,
"arguments": {"routes_yaml": "routes:\n - host: example.com\n"},
})
def test_missing_name_raises(self):
with self.assertRaises(_RpcError) as cm:
handle_tools_call({"arguments": {}}, self.config)
_tools_call(self.resolver, {"arguments": {}})
self.assertEqual(ERR_INVALID_PARAMS, cm.exception.code)
def test_arguments_must_be_object(self):
with self.assertRaises(_RpcError) as cm:
handle_tools_call(
{
"name": _sv.TOOL_EGRESS_ALLOW,
"arguments": [],
},
self.config,
)
_tools_call(self.resolver, {"name": _sv.TOOL_EGRESS_ALLOW, "arguments": []})
self.assertEqual(ERR_INVALID_PARAMS, cm.exception.code)
self.assertIn("must be an object", cm.exception.message)
def test_capability_block_call_raises_unknown_tool(self):
with self.assertRaises(_RpcError) as cm:
handle_tools_call(
{
"name": "capability-block",
"arguments": {
"dockerfile": "FROM python:3.13\n",
"justification": "need git",
},
_tools_call(self.resolver, {
"name": "capability-block",
"arguments": {
"dockerfile": "FROM python:3.13\n",
"justification": "need git",
},
self.config,
)
})
self.assertEqual(ERR_INVALID_PARAMS, cm.exception.code)
self.assertIn("unknown tool", cm.exception.message)
def test_archives_proposal_after_response(self):
responder = self._respond_when_proposal_appears(_sv.STATUS_APPROVED)
try:
handle_tools_call(
{
"name": _sv.TOOL_EGRESS_ALLOW,
"arguments": {
"routes_yaml": "routes:\n - host: example.com\n",
"justification": "x",
},
_tools_call(self.resolver, {
"name": _sv.TOOL_EGRESS_ALLOW,
"arguments": {
"routes_yaml": "routes:\n - host: example.com\n",
"justification": "x",
},
self.config,
)
})
finally:
responder.join()
# No pending proposals left after archive.
# No pending proposals left after the decided poll archives it.
self.assertEqual([], _sv.list_pending_proposals("dev"))
def test_pending_response_times_out_without_archive(self):
config = ServerConfig(
bottle_slug="dev",
response_timeout_seconds=0.05,
)
result = handle_tools_call(
result = _tools_call(
self.resolver,
{
"name": _sv.TOOL_EGRESS_ALLOW,
"arguments": {
@@ -433,9 +485,8 @@ class TestHandleToolsCall(unittest.TestCase):
"justification": "need egress",
},
},
config,
ServerConfig(response_timeout_seconds=0.05),
)
self.assertFalse(result["isError"]) # type: ignore[index]
text = result["content"][0]["text"] # type: ignore[index]
self.assertIn("status: pending", text)
@@ -510,7 +561,7 @@ class TestHttpEndToEnd(unittest.TestCase):
self.port = s.getsockname()[1]
s.close()
self.server = MCPServer(("127.0.0.1", self.port), MCPHandler)
self.server.config = ServerConfig(bottle_slug="dev")
self.server.config = ServerConfig()
self.thread = threading.Thread(
target=self.server.serve_forever, daemon=True,
)
@@ -552,23 +603,21 @@ class TestHttpEndToEnd(unittest.TestCase):
)
self.assertEqual(ERR_METHOD_NOT_FOUND, result["error"]["code"]) # type: ignore[index]
def test_internal_error_returns_err_internal_over_http(self):
with patch.object(
supervise_server._sv, "write_proposal",
side_effect=OSError("disk full"),
):
result = self._post_jsonrpc({
"jsonrpc": "2.0",
"id": 99,
"method": "tools/call",
"params": {
"name": _sv.TOOL_EGRESS_ALLOW,
"arguments": {
"routes_yaml": "routes:\n - host: example.com\n",
"justification": "x",
},
def test_no_resolver_fails_closed_over_http(self):
# The test server has no policy_resolver wired, so a proposal tools/call
# fails closed with ERR_INTERNAL rather than queuing anything.
result = self._post_jsonrpc({
"jsonrpc": "2.0",
"id": 99,
"method": "tools/call",
"params": {
"name": _sv.TOOL_EGRESS_ALLOW,
"arguments": {
"routes_yaml": "routes:\n - host: example.com\n",
"justification": "x",
},
})
},
})
self.assertIn("error", result)
self.assertEqual(ERR_INTERNAL, result["error"]["code"]) # type: ignore[index]
@@ -583,72 +632,23 @@ class TestHttpEndToEnd(unittest.TestCase):
conn.close()
class _FakeResolver:
def __init__(
self,
bottle_id: str | None = None,
raises: bool = False,
policy: str = "",
) -> None:
self._bottle_id = bottle_id
self._raises = raises
self._policy = policy
self.calls: list[str] = []
def resolve_bottle_id(self, source_ip: str, identity_token: str = "") -> str | None:
del identity_token
self.calls.append(source_ip)
if self._raises:
# Raise the exact class supervise_server catches (it imports
# policy_resolver flat inside the bundle, package-side in tests).
raise supervise_server.PolicyResolveError("orchestrator down")
return self._bottle_id
def resolve_policy_and_bottle_id(
self, source_ip: str, identity_token: str = "",
) -> "tuple[str, str | None, dict[str, str]]":
del identity_token
self.calls.append(source_ip)
if self._raises:
raise supervise_server.PolicyResolveError("orchestrator down")
return self._policy, self._bottle_id, {}
def _handler(resolver: object) -> MCPHandler:
"""A bare MCPHandler wired with a server (carrying the resolver) and a
client address, enough to exercise `_attributed_config` off-socket."""
client address, enough to exercise the resolver-backed paths off-socket."""
h: MCPHandler = MCPHandler.__new__(MCPHandler)
h.server = types.SimpleNamespace(policy_resolver=resolver) # type: ignore[assignment]
h.client_address = ("10.0.0.7", 4321)
h.headers = {} # type: ignore[assignment]
return h
class TestAttributedConfig(unittest.TestCase):
"""Each proposal is attributed to the calling bottle by source IP (PRD
0070); a server without a resolver fails closed rather than queuing under an
unattributed slug."""
class TestResolverFailClosed(unittest.TestCase):
"""A dispatch without a resolver is a misconfiguration, not a tenancy mode
fail closed rather than queue (or list) anything (PRD 0070)."""
def test_missing_resolver_fails_closed(self) -> None:
def test_missing_resolver_raises(self) -> None:
with self.assertRaises(_RpcInternalError):
_handler(None)._attributed_config(ServerConfig(bottle_slug="dev"))
def test_consolidated_binds_source_ip_bottle(self) -> None:
r = _FakeResolver(bottle_id="bottle-x")
cfg = _handler(r)._attributed_config(ServerConfig(bottle_slug="ignored"))
self.assertEqual("bottle-x", cfg.bottle_slug) # resolved slug wins
self.assertEqual(["10.0.0.7"], r.calls)
def test_unattributed_source_fails_closed(self) -> None:
with self.assertRaises(_RpcInternalError):
_handler(_FakeResolver(bottle_id=None))._attributed_config(
ServerConfig(bottle_slug="x")
)
def test_resolver_error_fails_closed(self) -> None:
with self.assertRaises(_RpcInternalError):
_handler(_FakeResolver(raises=True))._attributed_config(
ServerConfig(bottle_slug="x")
)
_handler(None)._resolver_or_fail()
class TestResolvedRoutesPayload(unittest.TestCase):
@@ -664,7 +664,7 @@ class TestResolvedRoutesPayload(unittest.TestCase):
" - host: www.google.com\n"
)
payload = _handler(
_FakeResolver(bottle_id="b1", policy=policy)
_FakeSuperviseResolver(bottle_id="b1", policy=policy)
)._resolved_routes_payload()
assert payload is not None
self.assertFalse(payload["isError"]) # type: ignore[index]
@@ -676,7 +676,7 @@ class TestResolvedRoutesPayload(unittest.TestCase):
# resolve_client_context swallows resolver errors → deny-all (empty),
# never another bottle's routes.
payload = _handler(
_FakeResolver(raises=True)
_FakeSuperviseResolver(raises=True)
)._resolved_routes_payload()
assert payload is not None
data = json.loads(payload["content"][0]["text"]) # type: ignore[index]
@@ -698,7 +698,7 @@ class TestNonBlockingSupervise(unittest.TestCase):
def setUp(self):
self._tmp = tempfile.TemporaryDirectory(prefix="supervise-nonblock-test.")
self._home_patch = use_bottle_root(Path(self._tmp.name) / ".bot-bottle")
self.config = ServerConfig(bottle_slug="dev")
self.resolver = _FakeSuperviseResolver("dev")
_qs.QueueStore("dev").migrate()
_as.AuditStore().migrate()
@@ -717,9 +717,6 @@ class TestNonBlockingSupervise(unittest.TestCase):
_sv.write_proposal(p)
return p
def _check(self, proposal_id: str) -> dict[str, object]:
return handle_check_proposal({"arguments": {"proposal_id": proposal_id}}, self.config)
# --- pending response carries the id ---
def test_pending_text_includes_id_and_pointer(self):
@@ -730,12 +727,13 @@ class TestNonBlockingSupervise(unittest.TestCase):
def test_tools_call_timeout_returns_pending_with_id_and_stays_queued(self):
# No responder → the grace window expires → pending, not blocked forever.
result = handle_tools_call(
result = _tools_call(
self.resolver,
{
"name": _sv.TOOL_EGRESS_ALLOW,
"arguments": {"routes_yaml": self._ROUTES, "justification": "x"},
},
ServerConfig(bottle_slug="dev", response_timeout_seconds=0.05),
ServerConfig(response_timeout_seconds=0.05),
)
self.assertFalse(result["isError"]) # type: ignore[index]
text = result["content"][0]["text"] # type: ignore[index]
@@ -749,7 +747,7 @@ class TestNonBlockingSupervise(unittest.TestCase):
def test_check_returns_approved_and_archives(self):
p = self._seed_proposal()
_sv.write_response("dev", _sv.Response(proposal_id=p.id, status=_sv.STATUS_APPROVED, notes="ok"))
result = self._check(p.id)
result = _check(self.resolver, {"arguments": {"proposal_id": p.id}})
self.assertFalse(result["isError"])
text = result["content"][0]["text"] # type: ignore[index]
self.assertIn("status: approved", text)
@@ -760,13 +758,13 @@ class TestNonBlockingSupervise(unittest.TestCase):
def test_check_rejected_sets_isError(self):
p = self._seed_proposal()
_sv.write_response("dev", _sv.Response(proposal_id=p.id, status=_sv.STATUS_REJECTED, notes="no"))
result = self._check(p.id)
result = _check(self.resolver, {"arguments": {"proposal_id": p.id}})
self.assertTrue(result["isError"])
self.assertIn("status: rejected", result["content"][0]["text"]) # type: ignore[index]
def test_check_pending_when_no_decision_yet(self):
p = self._seed_proposal()
result = self._check(p.id)
result = _check(self.resolver, {"arguments": {"proposal_id": p.id}})
self.assertFalse(result["isError"])
text = result["content"][0]["text"] # type: ignore[index]
self.assertIn("status: pending", text)
@@ -774,40 +772,41 @@ class TestNonBlockingSupervise(unittest.TestCase):
self.assertEqual(1, len(_sv.list_pending_proposals("dev"))) # not archived
def test_check_unknown_id_is_error(self):
result = self._check("no-such-proposal")
result = _check(self.resolver, {"arguments": {"proposal_id": "no-such-proposal"}})
self.assertTrue(result["isError"])
self.assertIn("status: unknown", result["content"][0]["text"]) # type: ignore[index]
def test_check_missing_id_raises(self):
with self.assertRaises(_RpcClientError) as cm:
handle_check_proposal({"arguments": {}}, self.config)
_check(self.resolver, {"arguments": {}})
self.assertEqual(ERR_INVALID_PARAMS, cm.exception.code)
def test_check_empty_id_raises(self):
with self.assertRaises(_RpcClientError) as cm:
handle_check_proposal({"arguments": {"proposal_id": " "}}, self.config)
_check(self.resolver, {"arguments": {"proposal_id": " "}})
self.assertEqual(ERR_INVALID_PARAMS, cm.exception.code)
def test_check_arguments_must_be_object(self):
with self.assertRaises(_RpcClientError) as cm:
handle_check_proposal({"arguments": []}, self.config)
_check(self.resolver, {"arguments": []})
self.assertEqual(ERR_INVALID_PARAMS, cm.exception.code)
def test_full_nonblocking_round_trip(self):
# 1. tools/call times out → pending with id
result = handle_tools_call(
result = _tools_call(
self.resolver,
{
"name": _sv.TOOL_EGRESS_ALLOW,
"arguments": {"routes_yaml": self._ROUTES, "justification": "x"},
},
ServerConfig(bottle_slug="dev", response_timeout_seconds=0.05),
ServerConfig(response_timeout_seconds=0.05),
)
pid = _sv.list_pending_proposals("dev")[0].id
self.assertIn(pid, result["content"][0]["text"]) # type: ignore[index]
# 2. operator decides out-of-band
_sv.write_response("dev", _sv.Response(proposal_id=pid, status=_sv.STATUS_APPROVED, notes="ok"))
# 3. agent resumes by polling — no re-proposing
poll = self._check(pid)
poll = _check(self.resolver, {"arguments": {"proposal_id": pid}})
self.assertFalse(poll["isError"])
self.assertIn("status: approved", poll["content"][0]["text"]) # type: ignore[index]
self.assertEqual([], _sv.list_pending_proposals("dev")) # resolved + archived