svrnty-hermes-webui-plugin/routes/umbrella.py
Svrnty ab24ff9cdb fix(plugin): umbrella.py registration broke /api/umbrella — sprint 2026-05-25 Wave 7 D12
Root cause: routes/umbrella.py:31 registered /umbrella (non-/api/* path)
which the plugin loader rejects with ValueError per SVRNTY-PLUGIN-PROTOCOL
§5.1. The ValueError bubbled out of register() before the two valid
/api/umbrella* routes could be registered, so /api/umbrella returned 404.

Note: task description fingered vault_status.py:46 as a "recursion bug",
but L46 there is handler.wfile.write(body) — no recursion. The _plugin_h
calls in tracebacks come from hermes-webui/api/routes.py:3462 (the
dispatcher, correctly invoking plugin handlers). The vault_status
BrokenPipeErrors are unrelated client-disconnect noise from a slow
credctl subprocess, not what breaks /api/umbrella.

Fix:
- Drop api.register_route("/umbrella", ...) line that violated /api/* contract
- Remove now-orphaned _handle_panel_html (Karpathy rule 3 cleanup)
- Add docstring noting umbrella panel HTML is reached via
  /plugins/svrnty/umbrella.html (already served by register_static)

Verified: /api/umbrella returns 200 + umbrella.json after restart.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 16:50:19 -04:00

110 lines
4.2 KiB
Python

"""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.
Note: the panel HTML lives at /plugins/svrnty/umbrella.html (served via
register_static). The /umbrella top-level route would require a non-/api/
handler, which the plugin loader doesn't allow (see SVRNTY-PLUGIN-PROTOCOL
§5.1 — register_route only accepts /api/*). Frontend nav links directly to
/plugins/svrnty/umbrella.html.
"""
log = api.logger("svrnty.routes.umbrella")
api.register_route("/api/umbrella", "GET", _handle_graph_json)
api.register_route("/api/umbrella/doc", "GET", _handle_doc_body)
log.info("umbrella 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