"""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 import tempfile import threading import time import types import unittest from pathlib import Path from unittest.mock import patch from tests.unit import use_bottle_root from bot_bottle import supervise as _sv from bot_bottle import queue_store as _qs from bot_bottle import audit_store as _as from bot_bottle import supervise_server # noqa: E402 from bot_bottle.supervise_server import ( ERR_INTERNAL, ERR_INVALID_PARAMS, ERR_INVALID_REQUEST, ERR_METHOD_NOT_FOUND, ERR_PARSE, MCPHandler, MCPServer, PROPOSED_FILE_FIELD, ServerConfig, TOOL_DEFINITIONS, _RpcClientError, _RpcError, _RpcInternalError, _response_timeout_from_env, format_pending_response_text, format_response_text, handle_check_proposal, handle_initialize, handle_tools_call, handle_tools_list, jsonrpc_error, jsonrpc_result, parse_jsonrpc, 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 ------------------------------------------------------------ class TestValidation(unittest.TestCase): def test_empty_proposed_file_rejected_for_tools_with_file_field(self): with self.assertRaises(_RpcError): validate_proposed_file(_sv.TOOL_EGRESS_ALLOW, " \n\t") def test_capability_block_rejected_as_unknown_tool(self): with self.assertRaises(_RpcError) as cm: validate_proposed_file("capability-block", "FROM python:3.13\n") self.assertEqual(ERR_INVALID_PARAMS, cm.exception.code) self.assertIn("unknown tool", cm.exception.message) def test_egress_routes_yaml_is_validated(self): validate_proposed_file( _sv.TOOL_EGRESS_ALLOW, "routes:\n - host: example.com\n", ) def test_invalid_egress_routes_yaml_rejected(self): with self.assertRaises(_RpcError): validate_proposed_file(_sv.TOOL_EGRESS_BLOCK, "routes: nope\n") def test_egress_routes_yaml_rejects_log_full(self): with self.assertRaises(_RpcError) as cm: validate_proposed_file( _sv.TOOL_EGRESS_ALLOW, "log: 2\nroutes:\n - host: example.com\n", ) self.assertEqual(ERR_INVALID_PARAMS, cm.exception.code) self.assertIn("must not change egress logging", cm.exception.message) # --- Error taxonomy -------------------------------------------------------- class TestRpcErrorTaxonomy(unittest.TestCase): def test_rpc_client_error_is_rpc_error(self): e = _RpcClientError(ERR_INVALID_PARAMS, "bad param") self.assertIsInstance(e, _RpcError) self.assertEqual(ERR_INVALID_PARAMS, e.code) self.assertEqual("bad param", e.message) def test_rpc_internal_error_is_rpc_error(self): e = _RpcInternalError("disk full") self.assertIsInstance(e, _RpcError) self.assertEqual(ERR_INTERNAL, e.code) self.assertEqual("disk full", e.message) def test_rpc_internal_error_preserves_cause(self): cause = OSError("no space left on device") try: raise _RpcInternalError("failed to write") from cause except _RpcInternalError as e: self.assertIs(cause, e.__cause__) def test_parse_error_is_client_error(self): with self.assertRaises(_RpcClientError): parse_jsonrpc(b"{bad json") def test_validation_error_is_client_error(self): with self.assertRaises(_RpcClientError): validate_proposed_file(_sv.TOOL_EGRESS_ALLOW, "routes: nope\n") def test_unknown_tool_in_tools_call_is_client_error(self): with self.assertRaises(_RpcClientError) as cm: _tools_call(_FakeSuperviseResolver(), {"name": "no-such-tool", "arguments": {}}) self.assertEqual(ERR_INVALID_PARAMS, cm.exception.code) 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 ------------------------------------------------------ class TestParseJsonRpc(unittest.TestCase): def test_parses_request_with_id(self): req = parse_jsonrpc( b'{"jsonrpc": "2.0", "id": 7, "method": "tools/list", "params": {}}' ) self.assertEqual("tools/list", req.method) self.assertEqual(7, req.id) self.assertFalse(req.is_notification) def test_parses_notification_no_id(self): req = parse_jsonrpc( b'{"jsonrpc": "2.0", "method": "notifications/initialized"}' ) self.assertTrue(req.is_notification) self.assertIsNone(req.id) def test_rejects_bad_json(self): with self.assertRaises(_RpcError) as cm: parse_jsonrpc(b"{not json") self.assertEqual(ERR_PARSE, cm.exception.code) def test_rejects_wrong_jsonrpc_version(self): with self.assertRaises(_RpcError) as cm: parse_jsonrpc(b'{"jsonrpc": "1.0", "method": "x"}') self.assertEqual(ERR_INVALID_REQUEST, cm.exception.code) def test_rejects_missing_method(self): with self.assertRaises(_RpcError): parse_jsonrpc(b'{"jsonrpc": "2.0"}') def test_treats_null_id_as_request(self): # JSON-RPC spec: id can be null for a request (just discouraged). req = parse_jsonrpc(b'{"jsonrpc": "2.0", "id": null, "method": "x"}') self.assertFalse(req.is_notification) self.assertIsNone(req.id) # --- JSON-RPC response framing -------------------------------------------- class TestJsonRpcFraming(unittest.TestCase): def test_result_envelope(self): body = jsonrpc_result(1, {"ok": True}) decoded = json.loads(body) self.assertEqual({"jsonrpc": "2.0", "id": 1, "result": {"ok": True}}, decoded) def test_error_envelope(self): body = jsonrpc_error(2, -32601, "method not found: foo") decoded = json.loads(body) self.assertEqual( {"jsonrpc": "2.0", "id": 2, "error": {"code": -32601, "message": "method not found: foo"}}, decoded, ) # --- MCP handlers ---------------------------------------------------------- class TestHandleInitialize(unittest.TestCase): def test_returns_protocol_version_and_caps(self): result = handle_initialize({}) self.assertEqual("2024-11-05", result["protocolVersion"]) self.assertIn("tools", result["capabilities"]) # type: ignore[index] self.assertEqual( "bot-bottle-supervise", result["serverInfo"]["name"], # type: ignore[index] ) class TestHandleToolsList(unittest.TestCase): def test_returns_all_tools(self): result = handle_tools_list({}) names = [t["name"] for t in result["tools"]] # type: ignore[index] self.assertEqual( sorted([ _sv.TOOL_EGRESS_ALLOW, _sv.TOOL_EGRESS_BLOCK, _sv.TOOL_LIST_EGRESS_ROUTES, _sv.TOOL_CHECK_PROPOSAL, ]), sorted(names), ) def test_remediation_tools_have_inputSchema_with_two_required_fields(self): # Only the proposal/remediation tools have required input # fields. The list-* introspection tools take no input. for tool in TOOL_DEFINITIONS: name = tool["name"] if name not in PROPOSED_FILE_FIELD: continue with self.subTest(name=name): schema = tool["inputSchema"] self.assertEqual("object", schema["type"]) # type: ignore[index] required = schema["required"] # type: ignore[index] self.assertEqual(2, len(required)) self.assertIn("justification", required) self.assertIn(PROPOSED_FILE_FIELD[name], required) # type: ignore[index] def test_list_egress_routes_takes_no_input(self): tool = next( t for t in TOOL_DEFINITIONS if t["name"] == _sv.TOOL_LIST_EGRESS_ROUTES ) schema = tool["inputSchema"] self.assertEqual({}, schema.get("properties")) # type: ignore[union-attr] # No `required` array because no inputs are required. self.assertNotIn("required", schema) # type: ignore[operator] def test_egress_tools_take_routes_yaml_and_justification(self): for tool_name in (_sv.TOOL_EGRESS_ALLOW, _sv.TOOL_EGRESS_BLOCK): with self.subTest(tool_name=tool_name): tool = next(t for t in TOOL_DEFINITIONS if t["name"] == tool_name) schema = tool["inputSchema"] self.assertEqual("object", schema["type"]) # type: ignore[index] self.assertEqual( ["routes_yaml", "justification"], schema["required"], # type: ignore[index] ) class TestHandleToolsCall(unittest.TestCase): def setUp(self): self._tmp = tempfile.TemporaryDirectory(prefix="supervise-server-test.") self._home_patch = use_bottle_root(Path(self._tmp.name) / ".bot-bottle") self.resolver = _FakeSuperviseResolver("dev") _qs.QueueStore("dev").migrate() _as.AuditStore().migrate() def tearDown(self): self._home_patch() self._tmp.cleanup() 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 — the operator half, out of band.""" def runner(): for _ in range(200): pending = _sv.list_pending_proposals("dev") if pending: p = pending[0] _sv.write_response("dev", _sv.Response( proposal_id=p.id, status=status, notes=notes, )) return time.sleep(0.01) t = threading.Thread(target=runner) t.start() return t def test_call_round_trips_through_queue(self): responder = self._respond_when_proposal_appears(_sv.STATUS_APPROVED, notes="lgtm") try: result = _tools_call(self.resolver, { "name": _sv.TOOL_EGRESS_BLOCK, "arguments": { "routes_yaml": "routes:\n - host: example.com\n", "justification": "need example.com", }, }) finally: responder.join() self.assertFalse(result["isError"]) # type: ignore[index] text = result["content"][0]["text"] # type: ignore[index] self.assertIn("status: approved", text) self.assertIn("notes: lgtm", text) def test_allow_round_trips_through_queue(self): responder = self._respond_when_proposal_appears(_sv.STATUS_APPROVED, notes="ok") try: result = _tools_call(self.resolver, { "name": _sv.TOOL_EGRESS_ALLOW, "arguments": { "routes_yaml": "routes:\n - host: example.com\n", "justification": "need example.com", }, }) finally: responder.join() self.assertFalse(result["isError"]) # type: ignore[index] text = result["content"][0]["text"] # type: ignore[index] self.assertIn("status: approved", text) self.assertIn("notes: ok", text) def test_rejected_response_sets_isError(self): responder = self._respond_when_proposal_appears(_sv.STATUS_REJECTED, notes="nope") try: result = _tools_call(self.resolver, { "name": _sv.TOOL_EGRESS_ALLOW, "arguments": { "routes_yaml": "routes:\n - host: example.com\n", "justification": "needed for tests", }, }) finally: responder.join() self.assertTrue(result["isError"]) # type: ignore[index] def test_invalid_tool_name_raises(self): with self.assertRaises(_RpcError) as cm: _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): _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: _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: _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: _tools_call(self.resolver, { "name": "capability-block", "arguments": { "dockerfile": "FROM python:3.13\n", "justification": "need git", }, }) 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: _tools_call(self.resolver, { "name": _sv.TOOL_EGRESS_ALLOW, "arguments": { "routes_yaml": "routes:\n - host: example.com\n", "justification": "x", }, }) finally: responder.join() # 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): result = _tools_call( self.resolver, { "name": _sv.TOOL_EGRESS_ALLOW, "arguments": { "routes_yaml": "routes:\n - host: example.com\n", "justification": "need egress", }, }, 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) self.assertIn("proposal remains queued", text) self.assertEqual(1, len(_sv.list_pending_proposals("dev"))) class TestResponseTimeoutEnv(unittest.TestCase): def test_unset_uses_default(self): self.assertEqual( supervise_server.DEFAULT_RESPONSE_TIMEOUT_SECONDS, _response_timeout_from_env({}), ) def test_positive_float_accepted(self): self.assertEqual( 12.5, _response_timeout_from_env({"SUPERVISE_RESPONSE_TIMEOUT_SECONDS": "12.5"}), ) def test_invalid_value_rejected(self): with self.assertRaises(ValueError): _response_timeout_from_env({"SUPERVISE_RESPONSE_TIMEOUT_SECONDS": "soon"}) def test_nonpositive_value_rejected(self): with self.assertRaises(ValueError): _response_timeout_from_env({"SUPERVISE_RESPONSE_TIMEOUT_SECONDS": "0"}) # --- Response text formatting --------------------------------------------- class TestFormatResponseText(unittest.TestCase): def test_approved_with_notes(self): text = format_response_text(_sv.Response( proposal_id="x", status=_sv.STATUS_APPROVED, notes="retry now", )) self.assertIn("status: approved", text) self.assertIn("notes: retry now", text) def test_modified_includes_modified_hint(self): text = format_response_text(_sv.Response( proposal_id="x", status=_sv.STATUS_MODIFIED, notes="", final_file="modified content", )) self.assertIn("status: modified", text) self.assertIn("the operator modified", text.lower()) class TestFormatPendingResponseText(unittest.TestCase): def test_formats_timeout_message(self): text = supervise_server.format_pending_response_text("prop-9", 12.5) self.assertIn("status: pending", text) self.assertIn("12.5s", text) self.assertIn("proposal_id: prop-9", text) # --- End-to-end HTTP sanity ------------------------------------------------ class TestHttpEndToEnd(unittest.TestCase): """Spin up the server on a random port and round-trip a tools/list over real HTTP. Catches the JSON-RPC plumbing if it ever drifts from the unit-level handlers.""" def setUp(self): self._tmp = tempfile.TemporaryDirectory(prefix="supervise-http-test.") # Pick a random port by binding to :0 first. import socket s = socket.socket() s.bind(("127.0.0.1", 0)) self.port = s.getsockname()[1] s.close() self.server = MCPServer(("127.0.0.1", self.port), MCPHandler) self.server.config = ServerConfig() self.thread = threading.Thread( target=self.server.serve_forever, daemon=True, ) self.thread.start() def tearDown(self): self.server.shutdown() self.server.server_close() self.thread.join(timeout=2) self._tmp.cleanup() def _post_jsonrpc(self, body: dict[str, object]) -> dict[str, object]: conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=5) try: payload = json.dumps(body).encode("utf-8") conn.request("POST", "/", body=payload, headers={"Content-Type": "application/json", "Content-Length": str(len(payload))}) resp = conn.getresponse() data = resp.read() return json.loads(data) finally: conn.close() def test_tools_list_over_http(self): result = self._post_jsonrpc( {"jsonrpc": "2.0", "id": 1, "method": "tools/list"}, ) self.assertEqual("2.0", result["jsonrpc"]) self.assertEqual(1, result["id"]) names = [t["name"] for t in result["result"]["tools"]] # type: ignore[index] self.assertNotIn("capability-block", names) self.assertIn(_sv.TOOL_EGRESS_ALLOW, names) self.assertIn(_sv.TOOL_EGRESS_BLOCK, names) def test_unknown_method_returns_jsonrpc_error(self): result = self._post_jsonrpc( {"jsonrpc": "2.0", "id": 2, "method": "does/not/exist"}, ) self.assertEqual(ERR_METHOD_NOT_FOUND, result["error"]["code"]) # type: ignore[index] 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] def test_health_endpoint(self): conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=5) try: conn.request("GET", "/health") resp = conn.getresponse() self.assertEqual(200, resp.status) self.assertEqual(b"ok\n", resp.read()) finally: conn.close() def _handler(resolver: object) -> MCPHandler: """A bare MCPHandler wired with a server (carrying the resolver) and a 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 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_raises(self) -> None: with self.assertRaises(_RpcInternalError): _handler(None)._resolver_or_fail() class TestResolvedRoutesPayload(unittest.TestCase): """`list-egress-routes` answers from the calling bottle's resolved policy — not the gateway's empty static table. Regression: an empty list led agents to propose replace-all route files that dropped base hosts like api.anthropic.com on approval.""" def test_returns_resolved_bottle_routes(self) -> None: policy = ( "routes:\n" " - host: api.anthropic.com\n" " - host: www.google.com\n" ) payload = _handler( _FakeSuperviseResolver(bottle_id="b1", policy=policy) )._resolved_routes_payload() assert payload is not None self.assertFalse(payload["isError"]) # type: ignore[index] data = json.loads(payload["content"][0]["text"]) # type: ignore[index] hosts = {r["host"] for r in data["routes"]} self.assertEqual({"api.anthropic.com", "www.google.com"}, hosts) def test_orchestrator_error_fails_closed_to_empty(self) -> None: # resolve_client_context swallows resolver errors → deny-all (empty), # never another bottle's routes. payload = _handler( _FakeSuperviseResolver(raises=True) )._resolved_routes_payload() assert payload is not None data = json.loads(payload["content"][0]["text"]) # type: ignore[index] self.assertEqual([], data["routes"]) def test_missing_resolver_fails_closed(self) -> None: # A server without a resolver is a misconfig, not a mode: raise rather # than list anything. with self.assertRaises(_RpcInternalError): _handler(None)._resolved_routes_payload() class TestNonBlockingSupervise(unittest.TestCase): """PRD prd-new / issue #412: pending responses carry the proposal id, and `check-proposal` polls a queued proposal without blocking or re-proposing.""" _ROUTES = "routes:\n - host: example.com\n" def setUp(self): self._tmp = tempfile.TemporaryDirectory(prefix="supervise-nonblock-test.") self._home_patch = use_bottle_root(Path(self._tmp.name) / ".bot-bottle") self.resolver = _FakeSuperviseResolver("dev") _qs.QueueStore("dev").migrate() _as.AuditStore().migrate() def tearDown(self): self._home_patch() self._tmp.cleanup() def _seed_proposal(self) -> "_sv.Proposal": p = _sv.Proposal.new( bottle_slug="dev", tool=_sv.TOOL_EGRESS_ALLOW, proposed_file=self._ROUTES, justification="need example.com", current_file_hash=_sv.sha256_hex(self._ROUTES), ) _sv.write_proposal(p) return p # --- pending response carries the id --- def test_pending_text_includes_id_and_pointer(self): text = format_pending_response_text("abc-123", 30.0) self.assertIn("status: pending", text) self.assertIn("proposal_id: abc-123", text) self.assertIn("check-proposal", text) def test_tools_call_timeout_returns_pending_with_id_and_stays_queued(self): # No responder → the grace window expires → pending, not blocked forever. result = _tools_call( self.resolver, { "name": _sv.TOOL_EGRESS_ALLOW, "arguments": {"routes_yaml": self._ROUTES, "justification": "x"}, }, 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) pending = _sv.list_pending_proposals("dev") self.assertEqual(1, len(pending)) # still queued, not archived self.assertIn(pending[0].id, text) # agent got the id to poll # --- check-proposal poll --- 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 = _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) self.assertIn("notes: ok", text) with self.assertRaises(FileNotFoundError): # archived on read _sv.read_proposal("dev", p.id) 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 = _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 = _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) self.assertIn(p.id, text) self.assertEqual(1, len(_sv.list_pending_proposals("dev"))) # not archived def test_check_unknown_id_is_error(self): 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: _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: _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: _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 = _tools_call( self.resolver, { "name": _sv.TOOL_EGRESS_ALLOW, "arguments": {"routes_yaml": self._ROUTES, "justification": "x"}, }, 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 = _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 if __name__ == "__main__": unittest.main()