Files
hermes-command-center/server.py
T
shawn 54af25d2da feat: config file editor with API endpoints, frontend UI, and tests
- Added GET /api/config/list - list whitelisted config files with metadata
- Added GET /api/config/read - read file content with line numbers
- Added POST /api/config/save - save with auto-backup and YAML validation
- Added POST /api/config/backup - manual backup endpoint
- Security: whitelist-based path validation, rejects paths outside whitelist
- Auto-backup before save with .bak.<timestamp> naming
- YAML validation for .yaml/.yml files
- Frontend: file list sidebar, code editor with line numbers, status bar
- Mobile-responsive layout (file list top, editor below)
- Config panel in nav, lazy-loaded when navigated to
- 13/13 tests passing
2026-06-01 23:49:30 -04:00

766 lines
27 KiB
Python

"""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="<html><body><h1>401 Unauthorized</h1></body></html>",
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="<h1>Command Center - Loading...</h1>")