"""Hermes Command Center - FastAPI backend with Network Status Dashboard.""" import asyncio import base64 import json import os import secrets import sqlite3 import ssl import subprocess import time from pathlib import Path import yaml from fastapi import FastAPI, Request, Response, Query from fastapi.staticfiles import StaticFiles from fastapi.responses import HTMLResponse, JSONResponse # ── Auth ────────────────────────────────────────────────────────────── DASHBOARD_USER = os.environ.get("DASHBOARD_USER", "shawn") DASHBOARD_PASS = os.environ.get("DASHBOARD_PASS", "Brett85!@") def _check_auth(auth_header: str | None) -> bool: if not auth_header: return False try: decoded = base64.b64decode(auth_header.removeprefix("Basic ")).decode() user, _, passwd = decoded.partition(":") return secrets.compare_digest(user, DASHBOARD_USER) and secrets.compare_digest(passwd, DASHBOARD_PASS) except Exception: return False _unauthorized = Response( content="

401 Unauthorized

", status_code=401, headers={"WWW-Authenticate": 'Basic realm="Command Center"'}, ) # ── Agent Profiles ────────────────────────────────────────────────── HERMES_BIN = "/home/oplabs/.hermes/hermes-agent/venv/bin/hermes" KNOWN_PROFILES = ["coder", "assistant", "artist", "kanban"] PROFILE_LABELS = { "coder": "Cody (Coder)", "assistant": "Ashley (Assistant)", "artist": "Artie (Artist)", "kanban": "Kanban Manager", } PROFILE_ICONS = { "coder": "💻", "assistant": "🤖", "artist": "🎨", "kanban": "📋", } def _get_profile_status(profile: str) -> dict: """Check if a Hermes profile gateway process is running.""" try: result = subprocess.run( ["ps", "aux"], capture_output=True, text=True, timeout=10, ) for line in result.stdout.splitlines(): if f"--profile {profile}" in line and "gateway run" in line: return {"status": "online", "profile": profile} return {"status": "offline", "profile": profile} except Exception: return {"status": "unknown", "profile": profile} def _get_all_profiles_status() -> list[dict]: """Return all known profiles with online/offline status.""" results = [] for p in KNOWN_PROFILES: st = _get_profile_status(p) st["label"] = PROFILE_LABELS.get(p, p.title()) st["icon"] = PROFILE_ICONS.get(p, "🤖") results.append(st) return results # ── Conversations Database ────────────────────────────────────────── CONVERSATIONS_DB = os.path.expanduser( "~/.hermes/kanban/boards/hermes-command-center/conversations.db" ) os.makedirs(os.path.dirname(CONVERSATIONS_DB), exist_ok=True) def _init_conversations_db(): """Create the conversations table if it doesn't exist.""" conn = sqlite3.connect(CONVERSATIONS_DB) try: conn.execute(""" CREATE TABLE IF NOT EXISTS conversations ( id INTEGER PRIMARY KEY AUTOINCREMENT, profile TEXT NOT NULL, role TEXT NOT NULL, content TEXT NOT NULL, created_at REAL NOT NULL ) """) conn.execute(""" CREATE INDEX IF NOT EXISTS idx_conversations_profile ON conversations(profile, created_at) """) conn.commit() finally: conn.close() def _save_message(profile: str, role: str, content: str): """Save a chat message to the conversation DB.""" conn = sqlite3.connect(CONVERSATIONS_DB) try: conn.execute( "INSERT INTO conversations (profile, role, content, created_at) VALUES (?, ?, ?, ?)", (profile, role, content, time.time()), ) conn.commit() finally: conn.close() def _get_conversation_history(profile: str, limit: int = 50) -> list[dict]: """Get recent conversation history for a profile.""" conn = sqlite3.connect(CONVERSATIONS_DB) try: rows = conn.execute( "SELECT role, content, created_at FROM conversations " "WHERE profile = ? ORDER BY created_at DESC LIMIT ?", (profile, limit), ).fetchall() messages = [ {"role": r[0], "content": r[1], "created_at": r[2]} for r in reversed(rows) ] return messages finally: conn.close() _init_conversations_db() app = FastAPI(title="Hermes Command Center") STATIC_DIR = Path(__file__).parent / "static" OBSIDIAN_VAULT = Path(os.path.expanduser("~/obsidian")).resolve() # ── Network Health Cache ────────────────────────────────────────────── INVENTORY_PATH = os.path.expanduser("~/.hermes/network-inventory.yaml") _network_cache: dict = {} CACHE_TTL = 30 # seconds def _load_inventory() -> dict: """Load network inventory from YAML.""" path = Path(INVENTORY_PATH) if not path.exists(): return {"hosts": [], "services": []} with open(path) as f: return yaml.safe_load(f) or {} def _get_host_ip(host: dict) -> str | None: """Return the best IP to check for a given host definition.""" if host.get("local_ip"): return host["local_ip"] if host.get("tailscale_ip"): return host["tailscale_ip"] return None async def _ping_host(ip: str, timeout: int = 2) -> float | None: """Ping a host and return latency in ms, or None if unreachable.""" try: start = time.monotonic() proc = await asyncio.create_subprocess_exec( "ping", "-c", "1", "-W", str(timeout), ip, stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.DEVNULL, ) await proc.wait() elapsed = (time.monotonic() - start) * 1000 if proc.returncode == 0: return round(elapsed, 1) return None except Exception: return None async def _check_port(ip: str, port: int, timeout: int = 2) -> bool: """Check if a TCP port is open on the given host.""" try: _, writer = await asyncio.wait_for( asyncio.open_connection(ip, port), timeout=timeout, ) writer.close() await writer.wait_closed() return True except (OSError, asyncio.TimeoutError): return False async def _check_http(url: str, timeout: int = 3) -> dict: """Check an HTTP(S) endpoint and return status info.""" import urllib.request try: req = urllib.request.Request(url, method="HEAD" if url.startswith("http") else "GET") start = time.monotonic() ctx = ssl._create_unverified_context() with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp: elapsed = (time.monotonic() - start) * 1000 return {"status": "online", "http_status": resp.status, "latency_ms": round(elapsed, 1)} except Exception as e: return {"status": "offline", "error": str(e)[:80]} def _get_local_services() -> list[dict]: """Parse ss -tlnp output for locally listening services.""" try: result = subprocess.run(["ss", "-tlnp"], capture_output=True, text=True, timeout=5) services = [] lines = result.stdout.strip().split("\n") for line in lines[1:]: if not line.strip(): continue parts = line.split() if len(parts) < 4: continue local = parts[3] addr, port_raw = local.rsplit(":", 1) proc = parts[-1] if len(parts) > 5 else "" addr = addr.strip("[]") services.append({ "local_address": addr, "port": port_raw, "process": proc, }) return services except (subprocess.TimeoutExpired, FileNotFoundError, IndexError) as e: return [{"error": str(e)}] async def _check_host(host: dict) -> dict: """Perform health checks on a single host and return enriched status.""" ip = _get_host_ip(host) if not ip: safe_host = {k: v for k, v in host.items() if k != "notes"} return {**safe_host, "ip": None, "status": "unknown", "reason": "no IP configured", "latency_ms": None, "ports": []} latency = await _ping_host(ip) known_ports = [] if host.get("ssh_port"): known_ports.append(("SSH", int(host["ssh_port"]))) checked_ports = [] for name, port in known_ports: open_port = await _check_port(ip, port) checked_ports.append({"name": name, "port": port, "open": open_port}) if latency is not None: safe_host = {k: v for k, v in host.items() if k != "notes"} return {**safe_host, "ip": ip, "status": "online", "latency_ms": latency, "ports": checked_ports} else: safe_host = {k: v for k, v in host.items() if k != "notes"} return {**safe_host, "ip": ip, "status": "offline", "latency_ms": None, "ports": checked_ports} async def _check_service(service: dict) -> dict: """Perform health check on a single service definition.""" result = {k: v for k, v in service.items() if k not in ("notes",)} result.setdefault("status", "unknown") result["latency_ms"] = None result["http_status"] = None # Try HTTP/S URL first if service.get("url"): check = await _check_http(service["url"]) result["status"] = check["status"] result["latency_ms"] = check.get("latency_ms") result["http_status"] = check.get("http_status") result["error"] = check.get("error") return result # Try port check on 127.0.0.1 (services defined as running locally) port = service.get("port") if port and service.get("category") != "infrastructure": is_open = await _check_port("127.0.0.1", int(port)) if is_open: result["status"] = "online" result["latency_ms"] = 0.0 else: result["status"] = "offline" return result async def _run_health_checks() -> dict: """Run all health checks and return combined results with caching.""" global _network_cache now = time.monotonic() if _network_cache and (now - _network_cache.get("_ts", 0)) < CACHE_TTL: return _network_cache inventory = _load_inventory() hosts = inventory.get("hosts", []) services = inventory.get("services", []) host_tasks = [_check_host(h) for h in hosts] host_results = await asyncio.gather(*host_tasks) service_tasks = [_check_service(s) for s in services] service_results = await asyncio.gather(*service_tasks) local_services = _get_local_services() result = { "hosts": host_results, "services": service_results, "local_services": local_services, "cached_at": time.time(), "cache_ttl": CACHE_TTL, } _network_cache = result _network_cache["_ts"] = now return result # ── Config Editor ────────────────────────────────────────────────────── HERMES_HOME = Path(os.path.expanduser("~/.hermes")).resolve() CONFIG_WHITELIST = [ {"path": "~/.hermes/.env", "filename": ".env", "label": ".env (Environment Variables)"}, {"path": "~/.hermes/config.yaml", "filename": "config.yaml", "label": "config.yaml (Main Config)"}, {"path": "~/.hermes/profiles/kanban/config.yaml", "filename": "config.yaml", "label": "kanban/config.yaml"}, {"path": "~/.hermes/profiles/coder/config.yaml", "filename": "config.yaml", "label": "coder/config.yaml"}, {"path": "~/.hermes/profiles/assistant/config.yaml", "filename": "config.yaml", "label": "assistant/config.yaml"}, {"path": "~/.hermes/profiles/kanban/SOUL.md", "filename": "SOUL.md", "label": "kanban/SOUL.md"}, {"path": "~/.hermes/profiles/kanban/AGENTS.md", "filename": "AGENTS.md", "label": "kanban/AGENTS.md"}, ] # Resolve whitelist once at startup CONFIG_PATHS: dict[str, dict] = {} for entry in CONFIG_WHITELIST: resolved = Path(entry["path"]).expanduser().resolve() CONFIG_PATHS[str(resolved)] = entry def _resolve_config_path(user_path: str) -> Path | None: """Resolve and validate a config path against the whitelist. Returns the resolved Path if whitelisted, None otherwise. Accepts both absolute paths and ~/ prefixed paths. """ candidate = Path(user_path).expanduser().resolve() if str(candidate) in CONFIG_PATHS: return candidate return None def _create_backup(filepath: Path) -> str: """Create a timestamped backup of the given file. Returns the backup path.""" bak = filepath.parent / f"{filepath.name}.bak.{int(time.time())}" bak.write_text(filepath.read_text(encoding="utf-8"), encoding="utf-8") return str(bak) def _validate_yaml(content: str) -> str | None: """Validate YAML content. Returns None if valid, or an error message.""" try: yaml.safe_load(content) return None except yaml.YAMLError as e: return f"YAML validation error: {e}" @ app.get("/api/config/list") async def config_list(): """List all editable config files with metadata.""" files = [] for entry in CONFIG_WHITELIST: fp = Path(entry["path"]).expanduser().resolve() info = { "path": str(fp), "filename": entry["filename"], "label": entry["label"], "missing": not fp.exists(), } if fp.exists(): stat = fp.stat() content = fp.read_text(encoding="utf-8") info["size"] = stat.st_size info["line_count"] = len(content.split("\n")) info["last_modified"] = int(stat.st_mtime) else: info["size"] = 0 info["line_count"] = 0 info["last_modified"] = None files.append(info) return {"files": files} @ app.get("/api/config/read") async def config_read(path: str = Query(..., description="Path to config file")): """Read a config file's content.""" fp = _resolve_config_path(path) if fp is None: return JSONResponse({"error": "Path not allowed or not found in whitelist"}, status_code=403) if not fp.exists(): return JSONResponse({"error": "File not found"}, status_code=404) try: content = fp.read_text(encoding="utf-8") except Exception as e: return JSONResponse({"error": f"Failed to read file: {e}"}, status_code=500) entry = CONFIG_PATHS.get(str(fp), {}) stat = fp.stat() return JSONResponse({ "path": str(fp), "filename": entry.get("filename", fp.name), "label": entry.get("label", fp.name), "content": content, "size": stat.st_size, "line_count": len(content.split("\n")), "last_modified": int(stat.st_mtime), }) @ app.post("/api/config/save") async def config_save(request: Request): """Save a config file with automatic backup and YAML validation.""" body = await request.json() path = body.get("path", "") content = body.get("content", "") if not path or content is None: return JSONResponse({"error": "Missing 'path' or 'content' in request body"}, status_code=400) fp = _resolve_config_path(path) if fp is None: return JSONResponse({"error": "Path not allowed or not found in whitelist"}, status_code=403) # YAML validation for .yaml/.yml files if fp.suffix.lower() in (".yaml", ".yml"): err = _validate_yaml(content) if err: return JSONResponse({"error": err}, status_code=400) # Auto-backup try: backup_path = _create_backup(fp) if fp.exists() else None except Exception as e: return JSONResponse({"error": f"Failed to create backup: {e}"}, status_code=500) # Save try: fp.write_text(content, encoding="utf-8") except Exception as e: return JSONResponse({"error": f"Failed to save file: {e}"}, status_code=500) stat = fp.stat() return JSONResponse({ "saved": True, "path": str(fp), "backup_path": backup_path, "size": stat.st_size, "line_count": len(content.split("\n")), "last_modified": int(stat.st_mtime), }) @ app.post("/api/config/backup") async def config_backup(path: str = Query(..., description="Path to config file to backup")): """Create a manual backup of a config file.""" fp = _resolve_config_path(path) if fp is None: return JSONResponse({"error": "Path not allowed or not found in whitelist"}, status_code=403) if not fp.exists(): return JSONResponse({"error": "File not found"}, status_code=404) try: backup_path = _create_backup(fp) except Exception as e: return JSONResponse({"error": f"Failed to create backup: {e}"}, status_code=500) return JSONResponse({"backup_path": backup_path, "path": str(fp)}) # ── Path security ───────────────────────────────────────────────────── def _resolve_vault_path(user_path: str) -> Path | None: """Resolve a user-supplied path relative to the vault root. Returns the resolved absolute Path if it's under OBSIDIAN_VAULT, or None if the path is invalid/outside the vault. """ safe = user_path.lstrip("/") candidate = (OBSIDIAN_VAULT / safe).resolve() try: candidate.relative_to(OBSIDIAN_VAULT) except ValueError: return None if not candidate.exists(): return None return candidate # ── Auth middleware ─────────────────────────────────────────────────── @app.middleware("http") async def auth_middleware(request: Request, call_next): if request.url.path in ("/health", "/api/health"): return await call_next(request) if request.url.path.startswith("/api/network/"): return await call_next(request) if not _check_auth(request.headers.get("Authorization")): return _unauthorized return await call_next(request) @app.get("/health") async def health(): return {"status": "ok", "service": "hermes-command-center"} # ── Network API Endpoints ──────────────────────────────────────────── @app.get("/api/network/hosts") async def network_hosts(): """List all hosts from inventory with live status.""" data = await _run_health_checks() return {"hosts": data["hosts"], "cached_at": data["cached_at"], "cache_ttl": data["cache_ttl"]} @app.get("/api/network/services") async def network_services(): """List all services with live health check results.""" data = await _run_health_checks() return {"services": data["services"], "cached_at": data["cached_at"], "cache_ttl": data["cache_ttl"]} @app.get("/api/network/services/local") async def network_services_local(): """List locally running services (from ss -tlnp).""" return {"local_services": _get_local_services()} @app.get("/api/network/refresh") async def network_refresh(): """Force a fresh health check of all hosts/services.""" global _network_cache _network_cache = {} data = await _run_health_checks() return { "hosts": data["hosts"], "services": data["services"], "local_services": data["local_services"], "cached_at": data["cached_at"], "cache_ttl": data["cache_ttl"], } # ── Vault API ───────────────────────────────────────────────────────── @app.get("/api/vault/tree") async def vault_tree(path: str = Query("", description="Relative path under vault")): resolved = _resolve_vault_path(path) if resolved is None: return JSONResponse({"error": "Path not found or outside vault"}, status_code=404) if not resolved.is_dir(): return JSONResponse({"error": "Not a directory"}, status_code=400) items = [] try: for entry in sorted(resolved.iterdir(), key=lambda e: (not e.is_dir(), e.name.lower())): if entry.name.startswith("."): continue info = { "name": entry.name, "type": "folder" if entry.is_dir() else "file", "size": entry.stat().st_size if entry.is_file() else 0, "modified": int(entry.stat().st_mtime), } items.append(info) except PermissionError: return JSONResponse({"error": "Permission denied"}, status_code=403) rel = resolved.relative_to(OBSIDIAN_VAULT) return JSONResponse({ "path": str(rel) if str(rel) != "." else "", "items": items, }) @app.get("/api/vault/read") async def vault_read(path: str = Query(..., description="Relative path to markdown file")): resolved = _resolve_vault_path(path) if resolved is None: return JSONResponse({"error": "File not found or outside vault"}, status_code=404) if not resolved.is_file(): return JSONResponse({"error": "Not a file"}, status_code=400) if resolved.suffix.lower() not in (".md", ".txt", ".markdown"): return JSONResponse({"error": "Cannot read non-text files"}, status_code=400) try: content = resolved.read_text(encoding="utf-8") except Exception as e: return JSONResponse({"error": f"Failed to read file: {e}"}, status_code=500) rel = resolved.relative_to(OBSIDIAN_VAULT) return JSONResponse({ "path": str(rel), "name": resolved.name, "content": content, "size": resolved.stat().st_size, "modified": int(resolved.stat().st_mtime), }) @app.get("/api/vault/search") async def vault_search(q: str = Query(..., min_length=1, description="Search query")): """Full-text grep-style search across all .md files under the vault root.""" results = [] try: cmd = [ "grep", "-rn", "--include=*.md", "-i", "-E", q, str(OBSIDIAN_VAULT), ] proc = subprocess.run(cmd, capture_output=True, text=True, timeout=15) if proc.returncode not in (0, 1): return JSONResponse({"error": f"Search failed: {proc.stderr.strip()}"}, status_code=500) for line in proc.stdout.splitlines(): parts = line.split(":", 2) if len(parts) < 3: continue filepath, lineno, content = parts fp = Path(filepath).resolve() try: fp.relative_to(OBSIDIAN_VAULT) except ValueError: continue rel_path = str(fp.relative_to(OBSIDIAN_VAULT)) idx = content.lower().find(q.lower()) start = max(0, idx - 60) end = min(len(content), idx + len(q) + 60) snippet = content[start:end].strip() if start > 0: snippet = "..." + snippet if end < len(content): snippet = snippet + "..." results.append({ "path": rel_path, "line": int(lineno), "snippet": snippet, }) except subprocess.TimeoutExpired: return JSONResponse({"error": "Search timed out"}, status_code=504) except FileNotFoundError: return JSONResponse({"error": "grep not available on this system"}, status_code=500) return JSONResponse({ "query": q, "count": len(results), "results": results, }) # ── Agent Chat API ────────────────────────────────────────────────── @app.get("/api/agents") async def agents_list(): """List available Hermes agent profiles with online/offline status.""" profiles = _get_all_profiles_status() return JSONResponse({"agents": profiles}) @app.post("/api/agents/{profile}/send") async def agent_send(profile: str, request: Request): """Send a message to a Hermes agent profile and return the response.""" if profile not in KNOWN_PROFILES: return JSONResponse({"error": f"Unknown profile: {profile}"}, status_code=404) try: body = await request.json() except Exception: return JSONResponse({"error": "Invalid JSON body"}, status_code=400) message = (body or {}).get("message", "").strip() if not message: return JSONResponse({"error": "Message is required"}, status_code=400) # Save user message _save_message(profile, "user", message) try: proc = await asyncio.create_subprocess_exec( HERMES_BIN, "-p", profile, "chat", "-q", message, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, env={**os.environ, "PYTHONUNBUFFERED": "1"}, ) try: stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=120) except asyncio.TimeoutError: proc.kill() return JSONResponse({"error": "Agent response timed out"}, status_code=504) if proc.returncode != 0: err = stderr.decode().strip() if stderr else "" return JSONResponse( {"error": f"Agent exited with code {proc.returncode}: {err[:200]}"}, status_code=502, ) response_text = stdout.decode().strip() if stdout else "" if not response_text: response_text = "(no response)" # Save agent response _save_message(profile, "agent", response_text) return JSONResponse({ "profile": profile, "response": response_text, }) except FileNotFoundError: return JSONResponse({"error": "Hermes binary not found"}, status_code=500) except Exception as e: return JSONResponse({"error": str(e)[:200]}, status_code=500) @app.get("/api/agents/{profile}/history") async def agent_history(profile: str, limit: int = Query(50, ge=1, le=200)): """Get recent conversation history for a profile.""" if profile not in KNOWN_PROFILES: return JSONResponse({"error": f"Unknown profile: {profile}"}, status_code=404) messages = _get_conversation_history(profile, limit=limit) return JSONResponse({"profile": profile, "messages": messages}) # ── Serve frontend ──────────────────────────────────────────────────── if STATIC_DIR.exists(): app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static") @app.get("/", response_class=HTMLResponse) async def index(): index_path = STATIC_DIR / "index.html" if index_path.exists(): return HTMLResponse(content=index_path.read_text()) return HTMLResponse(content="

Command Center - Loading...

")