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:
@@ -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
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user