refactor(gateway): remove the single-tenant data-plane paths (audit #400 finding 3)
All three backends (docker, firecracker, macos-container) now launch through the consolidated orchestrator, and every production gateway sets BOT_BOTTLE_ORCHESTRATOR_URL — so the legacy single-tenant (`resolver is None`) branches in the shared gateway's data plane were unreachable dead code, a second security-relevant path to keep correct in parallel with the live one. Make the orchestrator resolver mandatory and delete the single-tenant paths from the three data-plane modules. egress_addon.py: drop the static routes file entirely — EGRESS_ROUTES, _reload, the SIGHUP handler, self.config, and the SUPERVISE_BOTTLE_SLUG env slug. The per-request /resolve is the only policy source; __init__ fail-closes if BOT_BOTTLE_ORCHESTRATOR_URL is unset. Introspection (`_egress.local/allowlist`) now reports the calling bottle's *resolved* routes. The block/redact log gates and _req_ctx redaction now read the per-flow config/env from the request-time stash, so they use each bottle's log level and token set (they silently used the empty static config before). Nothing sends `docker kill --signal HUP` to the gateway in the consolidated model (the egress applicators fail closed), so removing the SIGHUP reload is safe. git_http_backend.py: resolver mandatory; no flat-root fallback. main() refuses to start without an orchestrator URL; a request whose source resolves to no bottle 404s. supervise_server.py: resolver mandatory; every proposal is attributed to the source-IP-resolved bottle. Remove handle_list_egress_routes (the proxy-fetch introspection that only worked when the proxy carried one bottle's identity) — list-egress-routes is answered from the resolved policy. main() refuses to start without an orchestrator URL. Tests: a host-side fake resolver serves each test's Config through the real parse path (a small YAML-subset emitter round-trips route_to_yaml_dict); the response/websocket hooks stash it as request() would. Deletes the tests for the removed static-config, SIGHUP-reload, and single-tenant-passthrough paths; adds fail-closed-without-orchestrator coverage. Follow-up: gateway_init still forwards SIGHUP to the egress child (now dormant — no one sends it); the README still describes the docker backend's per-bottle topology. Both are outside the data-plane teardown. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UoEZHDjv84ChoZbozQERhJ
This commit is contained in:
@@ -41,7 +41,6 @@ from bot_bottle.supervise_server import (
|
||||
_response_timeout_from_env,
|
||||
format_response_text,
|
||||
handle_initialize,
|
||||
handle_list_egress_routes,
|
||||
handle_tools_call,
|
||||
handle_tools_list,
|
||||
jsonrpc_error,
|
||||
@@ -448,49 +447,6 @@ class TestHandleToolsCall(unittest.TestCase):
|
||||
self.assertEqual(1, len(_sv.list_pending_proposals("dev")))
|
||||
|
||||
|
||||
class TestHandleListEgressRoutes(unittest.TestCase):
|
||||
def test_success_returns_body_text(self):
|
||||
class _Resp:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: object) -> bool:
|
||||
return False
|
||||
|
||||
def read(self):
|
||||
return b"[{\"host\": \"example.com\"}]"
|
||||
|
||||
class _Opener:
|
||||
def open(self, *args, **kwargs): # noqa: ANN001, ANN002, ANN003 # type: ignore
|
||||
return _Resp()
|
||||
|
||||
with patch.object(supervise_server.urllib.request, "build_opener", return_value=_Opener()):
|
||||
result = handle_list_egress_routes(
|
||||
{},
|
||||
ServerConfig(bottle_slug="dev"),
|
||||
)
|
||||
|
||||
self.assertFalse(result["isError"]) # type: ignore[index]
|
||||
text = result["content"][0]["text"] # type: ignore[index]
|
||||
self.assertIn("example.com", text)
|
||||
|
||||
def test_url_error_returns_tool_error(self):
|
||||
class _Opener:
|
||||
def open(self, *args, **kwargs): # noqa: ANN001, ANN002, ANN003 # type: ignore
|
||||
raise OSError("egress unavailable")
|
||||
|
||||
with patch.object(supervise_server.urllib.request, "build_opener", return_value=_Opener()):
|
||||
result = handle_list_egress_routes(
|
||||
{},
|
||||
ServerConfig(bottle_slug="dev"),
|
||||
)
|
||||
|
||||
self.assertTrue(result["isError"]) # type: ignore[index]
|
||||
text = result["content"][0]["text"] # type: ignore[index]
|
||||
self.assertIn("could not reach", text)
|
||||
self.assertIn("egress unavailable", text)
|
||||
|
||||
|
||||
class TestResponseTimeoutEnv(unittest.TestCase):
|
||||
def test_unset_uses_default(self):
|
||||
self.assertEqual(
|
||||
@@ -671,12 +627,13 @@ def _handler(resolver: object) -> MCPHandler:
|
||||
|
||||
|
||||
class TestAttributedConfig(unittest.TestCase):
|
||||
"""Consolidated supervise: each proposal is attributed to the calling
|
||||
bottle by source IP; single-tenant keeps the env slug (PRD 0070)."""
|
||||
"""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."""
|
||||
|
||||
def test_single_tenant_keeps_env_slug(self) -> None:
|
||||
cfg = _handler(None)._attributed_config(ServerConfig(bottle_slug="dev"))
|
||||
self.assertEqual("dev", cfg.bottle_slug)
|
||||
def test_missing_resolver_fails_closed(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")
|
||||
@@ -698,10 +655,10 @@ class TestAttributedConfig(unittest.TestCase):
|
||||
|
||||
|
||||
class TestResolvedRoutesPayload(unittest.TestCase):
|
||||
"""`list-egress-routes` answers from the calling bottle's resolved policy in
|
||||
consolidated mode — 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."""
|
||||
"""`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 = (
|
||||
@@ -728,9 +685,11 @@ class TestResolvedRoutesPayload(unittest.TestCase):
|
||||
data = json.loads(payload["content"][0]["text"]) # type: ignore[index]
|
||||
self.assertEqual([], data["routes"])
|
||||
|
||||
def test_single_tenant_returns_none(self) -> None:
|
||||
# No resolver → caller falls back to the static introspection endpoint.
|
||||
self.assertIsNone(_handler(None)._resolved_routes_payload())
|
||||
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()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user