"""GET /umbrella + /api/umbrella + /api/umbrella/doc — cortex-os umbrella graph viz. Per sot/03-PROTOCOLS/CORTEX-OS-UMBRELLA-VIZ-PRD.md. Endpoints: GET /umbrella → static HTML page (Cytoscape.js panel) GET /api/umbrella → graph/umbrella.json contents (UI-stable v1.0 schema) GET /api/umbrella/doc?path=X → markdown body + frontmatter for a node's source_path Reads graph artifact from $HERMES_REPO_ROOT/graph/umbrella.json (curator-maintained). Doc reads are restricted to hermes/ workspace subdir (no path traversal). Public API surface used: api.register_route, api.logger. No upstream forced internal dependencies. """ import json import os import urllib.parse from pathlib import Path _DEFAULT_REPO_ROOT = "/home/svrnty/workspaces/hermes" def _repo_root() -> Path: return Path(os.environ.get("HERMES_REPO_ROOT", _DEFAULT_REPO_ROOT)).resolve() def register(api): """Wire umbrella panel + APIs.""" log = api.logger("svrnty.routes.umbrella") api.register_route("/umbrella", "GET", _handle_panel_html) api.register_route("/api/umbrella", "GET", _handle_graph_json) api.register_route("/api/umbrella/doc", "GET", _handle_doc_body) log.info("umbrella panel + APIs registered") def _send(handler, status: int, body: bytes, content_type: str) -> None: handler.send_response(status) handler.send_header("Content-Type", content_type) handler.send_header("Content-Length", str(len(body))) handler.send_header("Cache-Control", "no-store") handler.end_headers() handler.wfile.write(body) def _handle_graph_json(handler, parsed) -> bool: graph_path = _repo_root() / "graph" / "umbrella.json" if not graph_path.exists(): body = json.dumps({ "error": "graph/umbrella.json not found", "hint": "run curator/sweep.py --grapher-only to generate", }).encode("utf-8") _send(handler, 404, body, "application/json; charset=utf-8") return True try: body = graph_path.read_bytes() except OSError as e: body = json.dumps({"error": str(e)}).encode("utf-8") _send(handler, 500, body, "application/json; charset=utf-8") return True _send(handler, 200, body, "application/json; charset=utf-8") return True def _handle_doc_body(handler, parsed) -> bool: qs = urllib.parse.parse_qs(parsed.query or "") rel = (qs.get("path") or [""])[0].strip() if not rel: _send(handler, 400, b'{"error":"missing path query param"}', "application/json; charset=utf-8") return True root = _repo_root() try: target = (root / rel).resolve() except (OSError, RuntimeError): _send(handler, 400, b'{"error":"invalid path"}', "application/json; charset=utf-8") return True # Path-traversal guard: resolved target must be inside the workspace root. if root not in target.parents and target != root: _send(handler, 403, b'{"error":"path outside workspace"}', "application/json; charset=utf-8") return True if not target.exists() or not target.is_file(): _send(handler, 404, json.dumps({"error": f"file not found: {rel}"}).encode("utf-8"), "application/json; charset=utf-8") return True try: content = target.read_text(encoding="utf-8") except (OSError, UnicodeDecodeError) as e: _send(handler, 500, json.dumps({"error": str(e)}).encode("utf-8"), "application/json; charset=utf-8") return True # Cap response size — UI only renders a preview pane; large docs read in full via git. MAX = 50_000 truncated = len(content) > MAX if truncated: content = content[:MAX] + "\n\n*[truncated — read full file in repo]*" body = json.dumps({ "path": rel, "size": len(content), "truncated": truncated, "body": content, }).encode("utf-8") _send(handler, 200, body, "application/json; charset=utf-8") return True def _handle_panel_html(handler, parsed) -> bool: """Serve the static umbrella.html via Location redirect (assets live in static/).""" handler.send_response(302) handler.send_header("Location", "/plugins/svrnty/umbrella.html") handler.end_headers() return True