diff --git a/.gitea/workflows/weekly-audit.yml b/.gitea/workflows/weekly-audit.yml new file mode 100644 index 0000000..1d32a32 --- /dev/null +++ b/.gitea/workflows/weekly-audit.yml @@ -0,0 +1,28 @@ +name: Weekly Privacy Audit + +# Runs every Monday at 05:00 UTC against a CI test fixture. +# On production wizards this same script should be run via cron: +# 0 5 * * 1 python /opt/nexus/mempalace/audit_privacy.py /var/lib/mempalace/fleet +# +# Refs: #1083, #1075 + +on: + schedule: + - cron: "0 5 * * 1" # Monday 05:00 UTC + workflow_dispatch: {} # allow manual trigger + +jobs: + privacy-audit: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v4 + with: + python-version: "3.x" + + - name: Run privacy audit against CI fixture + run: | + python mempalace/audit_privacy.py tests/fixtures/fleet_palace diff --git a/mempalace/fleet_api.py b/mempalace/fleet_api.py new file mode 100644 index 0000000..8004f0b --- /dev/null +++ b/mempalace/fleet_api.py @@ -0,0 +1,186 @@ +#!/usr/bin/env python3 +""" +fleet_api.py — Lightweight HTTP API for the shared fleet palace. + +Exposes fleet memory search over HTTP so that Alpha servers and other +wizard deployments can query the palace without direct filesystem access. + +Endpoints: + GET /health + Returns {"status": "ok", "palace": ""} + + GET /search?q=[&room=][&n=] + Returns {"results": [...], "query": "...", "room": "...", "count": N} + Each result: {"text": "...", "room": "...", "wing": "...", "score": 0.9} + + GET /wings + Returns {"wings": ["bezalel", ...]} — distinct wizard wings present + +Error responses use {"error": ""} with appropriate HTTP status codes. + +Usage: + # Default: localhost:7771, fleet palace at /var/lib/mempalace/fleet + python mempalace/fleet_api.py + + # Custom host/port/palace: + FLEET_PALACE_PATH=/data/fleet python mempalace/fleet_api.py --host 0.0.0.0 --port 8080 + +Refs: #1078, #1075 +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +from http.server import BaseHTTPRequestHandler, HTTPServer +from pathlib import Path +from urllib.parse import parse_qs, urlparse + +# Add repo root to path so we can import nexus.mempalace +_HERE = Path(__file__).resolve().parent +_REPO_ROOT = _HERE.parent +if str(_REPO_ROOT) not in sys.path: + sys.path.insert(0, str(_REPO_ROOT)) + +DEFAULT_HOST = "127.0.0.1" +DEFAULT_PORT = 7771 +MAX_RESULTS = 50 + + +def _get_palace_path() -> Path: + return Path(os.environ.get("FLEET_PALACE_PATH", "/var/lib/mempalace/fleet")) + + +def _json_response(handler: BaseHTTPRequestHandler, status: int, body: dict) -> None: + payload = json.dumps(body).encode() + handler.send_response(status) + handler.send_header("Content-Type", "application/json") + handler.send_header("Content-Length", str(len(payload))) + handler.end_headers() + handler.wfile.write(payload) + + +def _handle_health(handler: BaseHTTPRequestHandler) -> None: + palace = _get_palace_path() + _json_response(handler, 200, { + "status": "ok", + "palace": str(palace), + "palace_exists": palace.exists(), + }) + + +def _handle_search(handler: BaseHTTPRequestHandler, qs: dict) -> None: + query_terms = qs.get("q", [""]) + q = query_terms[0].strip() if query_terms else "" + if not q: + _json_response(handler, 400, {"error": "Missing required parameter: q"}) + return + + room_terms = qs.get("room", []) + room = room_terms[0].strip() if room_terms else None + + n_terms = qs.get("n", []) + try: + n = max(1, min(int(n_terms[0]), MAX_RESULTS)) if n_terms else 10 + except (ValueError, IndexError): + _json_response(handler, 400, {"error": "Invalid parameter: n must be an integer"}) + return + + try: + from nexus.mempalace.searcher import search_fleet, MemPalaceUnavailable + except ImportError as exc: + _json_response(handler, 503, {"error": f"MemPalace module not available: {exc}"}) + return + + try: + results = search_fleet(q, room=room, n_results=n) + except Exception as exc: # noqa: BLE001 + _json_response(handler, 503, {"error": str(exc)}) + return + + _json_response(handler, 200, { + "query": q, + "room": room, + "count": len(results), + "results": [ + { + "text": r.text, + "room": r.room, + "wing": r.wing, + "score": round(r.score, 4), + } + for r in results + ], + }) + + +def _handle_wings(handler: BaseHTTPRequestHandler) -> None: + """Return distinct wizard wing names found in the fleet palace directory.""" + palace = _get_palace_path() + if not palace.exists(): + _json_response(handler, 503, { + "error": f"Fleet palace not found: {palace}", + }) + return + + wings = sorted({ + p.name for p in palace.iterdir() if p.is_dir() + }) + _json_response(handler, 200, {"wings": wings}) + + +class FleetAPIHandler(BaseHTTPRequestHandler): + """Request handler for the fleet memory API.""" + + def log_message(self, fmt: str, *args) -> None: # noqa: ANN001 + # Prefix with tag for easier log filtering + sys.stderr.write(f"[fleet_api] {fmt % args}\n") + + def do_GET(self) -> None: # noqa: N802 + parsed = urlparse(self.path) + path = parsed.path.rstrip("/") or "/" + qs = parse_qs(parsed.query) + + if path == "/health": + _handle_health(self) + elif path == "/search": + _handle_search(self, qs) + elif path == "/wings": + _handle_wings(self) + else: + _json_response(self, 404, { + "error": f"Unknown endpoint: {path}", + "endpoints": ["/health", "/search", "/wings"], + }) + + +def make_server(host: str = DEFAULT_HOST, port: int = DEFAULT_PORT) -> HTTPServer: + return HTTPServer((host, port), FleetAPIHandler) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description="Fleet palace HTTP API server." + ) + parser.add_argument("--host", default=DEFAULT_HOST, help=f"Bind host (default: {DEFAULT_HOST})") + parser.add_argument("--port", type=int, default=DEFAULT_PORT, help=f"Bind port (default: {DEFAULT_PORT})") + args = parser.parse_args(argv) + + palace = _get_palace_path() + print(f"[fleet_api] Palace: {palace}") + if not palace.exists(): + print(f"[fleet_api] WARNING: palace path does not exist yet: {palace}", file=sys.stderr) + + server = make_server(args.host, args.port) + print(f"[fleet_api] Listening on http://{args.host}:{args.port}") + try: + server.serve_forever() + except KeyboardInterrupt: + print("\n[fleet_api] Shutting down.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/fixtures/fleet_palace/bezalel/forge.closet.json b/tests/fixtures/fleet_palace/bezalel/forge.closet.json new file mode 100644 index 0000000..ab98464 --- /dev/null +++ b/tests/fixtures/fleet_palace/bezalel/forge.closet.json @@ -0,0 +1,16 @@ +{ + "wizard": "bezalel", + "room": "forge", + "drawers": [ + { + "text": "CI pipeline green on main. All 253 tests passing.", + "source_file": "forge.closet.json", + "closet": true + }, + { + "text": "Deployed nexus heartbeat cron fix to Beta. Poka-yoke checks pass.", + "source_file": "forge.closet.json", + "closet": true + } + ] +} diff --git a/tests/fixtures/fleet_palace/bezalel/hermes.closet.json b/tests/fixtures/fleet_palace/bezalel/hermes.closet.json new file mode 100644 index 0000000..8bdf211 --- /dev/null +++ b/tests/fixtures/fleet_palace/bezalel/hermes.closet.json @@ -0,0 +1,11 @@ +{ + "wizard": "bezalel", + "room": "hermes", + "drawers": [ + { + "text": "Hermes gateway v2 deployed. MCP tools registered: mempalace, gitea, cron.", + "source_file": "hermes.closet.json", + "closet": true + } + ] +} diff --git a/tests/fixtures/fleet_palace/bezalel/issues.closet.json b/tests/fixtures/fleet_palace/bezalel/issues.closet.json new file mode 100644 index 0000000..1750267 --- /dev/null +++ b/tests/fixtures/fleet_palace/bezalel/issues.closet.json @@ -0,0 +1,11 @@ +{ + "wizard": "bezalel", + "room": "issues", + "drawers": [ + { + "text": "MemPalace x Evennia milestone: 6 of 8 issues closed. #1078 and #1083 in progress.", + "source_file": "issues.closet.json", + "closet": true + } + ] +} diff --git a/tests/test_mempalace_fleet_api.py b/tests/test_mempalace_fleet_api.py new file mode 100644 index 0000000..f281eab --- /dev/null +++ b/tests/test_mempalace_fleet_api.py @@ -0,0 +1,239 @@ +""" +Tests for mempalace/fleet_api.py — Alpha-side HTTP fleet memory API. + +Refs: #1078, #1075 +""" + +from __future__ import annotations + +import io +import json +import threading +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +# Import handler directly so we can test without running a server process. +from mempalace.fleet_api import FleetAPIHandler, _handle_health, _handle_search, _handle_wings, make_server + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +class _FakeSocket: + """Minimal socket stub for BaseHTTPRequestHandler.""" + + def makefile(self, mode: str, *args, **kwargs): # noqa: ANN001 + return io.BytesIO(b"") + + +def _make_handler(path: str = "/health") -> tuple[FleetAPIHandler, io.BytesIO]: + """Construct a handler pointed at *path*, capture wfile output.""" + buf = io.BytesIO() + request = _FakeSocket() + client_address = ("127.0.0.1", 0) + + handler = FleetAPIHandler.__new__(FleetAPIHandler) + handler.path = path + handler.request = request + handler.client_address = client_address + handler.server = MagicMock() + handler.wfile = buf + handler.rfile = io.BytesIO(b"") + handler.command = "GET" + handler._headers_buffer = [] + + # Stub send_response / send_header / end_headers to write minimal HTTP + handler._response_code = None + def _send_response(code, message=None): # noqa: ANN001 + handler._response_code = code + def _send_header(k, v): # noqa: ANN001 + pass + def _end_headers(): # noqa: ANN001 + pass + handler.send_response = _send_response + handler.send_header = _send_header + handler.end_headers = _end_headers + + return handler, buf + + +def _parse_response(buf: io.BytesIO) -> dict: + buf.seek(0) + return json.loads(buf.read()) + + +# --------------------------------------------------------------------------- +# /health +# --------------------------------------------------------------------------- + +def test_health_returns_ok(tmp_path, monkeypatch): + monkeypatch.setenv("FLEET_PALACE_PATH", str(tmp_path)) + handler, buf = _make_handler("/health") + _handle_health(handler) + data = _parse_response(buf) + assert data["status"] == "ok" + assert data["palace_exists"] is True + + +def test_health_missing_palace(tmp_path, monkeypatch): + missing = tmp_path / "nonexistent" + monkeypatch.setenv("FLEET_PALACE_PATH", str(missing)) + handler, buf = _make_handler("/health") + _handle_health(handler) + data = _parse_response(buf) + assert data["status"] == "ok" + assert data["palace_exists"] is False + + +# --------------------------------------------------------------------------- +# /search +# --------------------------------------------------------------------------- + +def _mock_search_fleet(results): + """Return a patch target that returns *results*.""" + mock = MagicMock(return_value=results) + return mock + + +def _make_result(text="hello", room="forge", wing="bezalel", score=0.9): + from nexus.mempalace.searcher import MemPalaceResult + return MemPalaceResult(text=text, room=room, wing=wing, score=score) + + +def test_search_missing_q_param(): + handler, buf = _make_handler("/search") + _handle_search(handler, {}) + data = _parse_response(buf) + assert "error" in data + assert handler._response_code == 400 + + +def test_search_returns_results(tmp_path, monkeypatch): + monkeypatch.setenv("FLEET_PALACE_PATH", str(tmp_path)) + (tmp_path / "chroma.sqlite3").touch() + result = _make_result(text="CI green", room="forge", wing="bezalel", score=0.95) + + with patch("mempalace.fleet_api.FleetAPIHandler") as _: + handler, buf = _make_handler("/search?q=CI") + + import nexus.mempalace.searcher as s_module + with patch.object(s_module, "search_fleet", return_value=[result]): + import importlib + import mempalace.fleet_api as api_module + # Patch search_fleet inside the handler's import context + with patch("nexus.mempalace.searcher.search_fleet", return_value=[result]): + _handle_search(handler, {"q": ["CI"]}) + + data = _parse_response(buf) + assert data["count"] == 1 + assert data["results"][0]["text"] == "CI green" + assert data["results"][0]["room"] == "forge" + assert data["results"][0]["wing"] == "bezalel" + assert data["results"][0]["score"] == 0.95 + assert handler._response_code == 200 + + +def test_search_with_room_filter(tmp_path, monkeypatch): + monkeypatch.setenv("FLEET_PALACE_PATH", str(tmp_path)) + result = _make_result() + + import nexus.mempalace.searcher as s_module + with patch.object(s_module, "search_fleet", return_value=[result]) as mock_sf: + _handle_search(MagicMock(), {"q": ["test"], "room": ["hermes"]}) + + # Verify room was passed through + mock_sf.assert_called_once_with("test", room="hermes", n_results=10) + + +def test_search_invalid_n_param(): + handler, buf = _make_handler("/search?q=test&n=bad") + _handle_search(handler, {"q": ["test"], "n": ["bad"]}) + data = _parse_response(buf) + assert "error" in data + assert handler._response_code == 400 + + +def test_search_palace_unavailable(monkeypatch): + from nexus.mempalace.searcher import MemPalaceUnavailable + + handler, buf = _make_handler("/search?q=test") + + import nexus.mempalace.searcher as s_module + with patch.object(s_module, "search_fleet", side_effect=MemPalaceUnavailable("no palace")): + _handle_search(handler, {"q": ["test"]}) + + data = _parse_response(buf) + assert "error" in data + assert handler._response_code == 503 + + +def test_search_n_clamped_to_max(): + """n > MAX_RESULTS is silently clamped.""" + import nexus.mempalace.searcher as s_module + with patch.object(s_module, "search_fleet", return_value=[]) as mock_sf: + handler = MagicMock() + _handle_search(handler, {"q": ["test"], "n": ["9999"]}) + + mock_sf.assert_called_once_with("test", room=None, n_results=50) + + +# --------------------------------------------------------------------------- +# /wings +# --------------------------------------------------------------------------- + +def test_wings_returns_list(tmp_path, monkeypatch): + monkeypatch.setenv("FLEET_PALACE_PATH", str(tmp_path)) + (tmp_path / "bezalel").mkdir() + (tmp_path / "timmy").mkdir() + # A file should not appear in wings + (tmp_path / "README.txt").touch() + + handler, buf = _make_handler("/wings") + _handle_wings(handler) + data = _parse_response(buf) + assert set(data["wings"]) == {"bezalel", "timmy"} + assert handler._response_code == 200 + + +def test_wings_missing_palace(tmp_path, monkeypatch): + missing = tmp_path / "nonexistent" + monkeypatch.setenv("FLEET_PALACE_PATH", str(missing)) + handler, buf = _make_handler("/wings") + _handle_wings(handler) + data = _parse_response(buf) + assert "error" in data + assert handler._response_code == 503 + + +# --------------------------------------------------------------------------- +# 404 unknown endpoint +# --------------------------------------------------------------------------- + +def test_unknown_endpoint(): + handler, buf = _make_handler("/foobar") + handler.do_GET() + data = _parse_response(buf) + assert "error" in data + assert handler._response_code == 404 + assert "/search" in data["endpoints"] + + +# --------------------------------------------------------------------------- +# audit fixture smoke test +# --------------------------------------------------------------------------- + +def test_audit_fixture_is_clean(): + """Ensure tests/fixtures/fleet_palace/ passes privacy audit (no violations).""" + from mempalace.audit_privacy import audit_palace + + fixture_dir = Path(__file__).parent / "fixtures" / "fleet_palace" + assert fixture_dir.exists(), f"Fixture directory missing: {fixture_dir}" + + result = audit_palace(fixture_dir) + assert result.clean, ( + f"Privacy violations found in CI fixture:\n" + + "\n".join(f" [{v.rule}] {v.path}: {v.detail}" for v in result.violations) + )