Files
hermes-agent/gateway/web_console/api/system.py
Alexander Whitestone 8e0f24db3f
Some checks failed
Forge CI / smoke-and-build (pull_request) Failing after 59s
feat(web-console): cherry-pick React web console GUI from gary-the-ai fork
Cherry-pick the Hermes Web Console from gary-the-ai/hermes-web-console-gui.
React + TypeScript frontend with Vite, Python aiohttp backend API.

Components:
- web_console/ — React frontend (chat, sessions, memory, settings, skills,
  gateway config, cron, workspace, tools, browser, insights pages)
- gateway/web_console/ — Python backend API (23 endpoints, SSE event bus,
  11 service modules)
- gateway/platforms/api_server_ui.py — embedded browser UI for API server
- gateway/platforms/api_server.py — route registration refactored into
  _register_routes(), web console mounted via maybe_register_web_console()
- run-gui.sh / setup-gui.sh — one-command launch and setup scripts
- tests/gateway/test_api_server_gui_mount.py — 4 integration tests (passing)
- tests/web_console/ — 13 backend test files (51 passing)
- docs/plans/ — implementation plan, API schema, frontend architecture

Fix: added missing ModelContextError class and CRON_MIN_CONTEXT_TOKENS to
cron/scheduler.py (pre-existing import bug).

Closes #325
2026-04-13 18:01:51 -04:00

179 lines
5.8 KiB
Python

"""Backup and restore API routes for the Hermes Web Console.
Provides:
GET /api/gui/system/backup — stream a zip backup of ~/.hermes/ to the browser
POST /api/gui/system/restore — accept a zip upload and restore into ~/.hermes/
"""
from __future__ import annotations
import asyncio
import io
import logging
import os
import tempfile
import time
import zipfile
from datetime import datetime
from pathlib import Path
from aiohttp import web
logger = logging.getLogger(__name__)
async def handle_system_backup(request: web.Request) -> web.StreamResponse:
"""Stream a zip backup of HERMES_HOME to the browser."""
loop = asyncio.get_running_loop()
def _create_backup_bytes() -> tuple[bytes, int, str]:
from hermes_constants import get_default_hermes_root
from hermes_cli.backup import _should_exclude, _EXCLUDED_DIRS
hermes_root = get_default_hermes_root()
if not hermes_root.is_dir():
raise FileNotFoundError(f"Hermes home not found: {hermes_root}")
stamp = datetime.now().strftime("%Y-%m-%d-%H%M%S")
filename = f"hermes-backup-{stamp}.zip"
buf = io.BytesIO()
file_count = 0
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED, compresslevel=6) as zf:
for dirpath, dirnames, filenames in os.walk(hermes_root, followlinks=False):
dp = Path(dirpath)
rel_dir = dp.relative_to(hermes_root)
dirnames[:] = [d for d in dirnames if d not in _EXCLUDED_DIRS]
for fname in filenames:
fpath = dp / fname
rel = fpath.relative_to(hermes_root)
if _should_exclude(rel):
continue
try:
zf.write(fpath, arcname=str(rel))
file_count += 1
except (PermissionError, OSError):
continue
return buf.getvalue(), file_count, filename
try:
data, file_count, filename = await loop.run_in_executor(None, _create_backup_bytes)
except FileNotFoundError as e:
return web.json_response({"ok": False, "error": str(e)}, status=404)
except Exception as e:
logger.exception("Backup creation failed")
return web.json_response({"ok": False, "error": str(e)}, status=500)
response = web.StreamResponse(
status=200,
headers={
"Content-Type": "application/zip",
"Content-Disposition": f'attachment; filename="{filename}"',
"Content-Length": str(len(data)),
"X-Backup-Files": str(file_count),
},
)
await response.prepare(request)
await response.write(data)
await response.write_eof()
return response
async def handle_system_restore(request: web.Request) -> web.Response:
"""Accept a zip upload and restore it into HERMES_HOME."""
reader = await request.multipart()
if reader is None:
return web.json_response(
{"ok": False, "error": "Expected multipart/form-data with a 'file' part"},
status=400,
)
zip_data = None
while True:
part = await reader.next()
if part is None:
break
if part.name == "file":
zip_data = await part.read(decode=False)
break
if zip_data is None:
return web.json_response(
{"ok": False, "error": "No 'file' part found in upload"},
status=400,
)
loop = asyncio.get_running_loop()
def _do_restore() -> dict:
from hermes_constants import get_default_hermes_root
from hermes_cli.backup import _validate_backup_zip, _detect_prefix
hermes_root = get_default_hermes_root()
hermes_root.mkdir(parents=True, exist_ok=True)
buf = io.BytesIO(zip_data)
if not zipfile.is_zipfile(buf):
return {"ok": False, "error": "Uploaded data is not a valid zip file"}
buf.seek(0)
with zipfile.ZipFile(buf, "r") as zf:
ok, reason = _validate_backup_zip(zf)
if not ok:
return {"ok": False, "error": reason}
prefix = _detect_prefix(zf)
members = [n for n in zf.namelist() if not n.endswith("/")]
errors = []
restored = 0
for member in members:
if prefix and member.startswith(prefix):
rel = member[len(prefix):]
else:
rel = member
if not rel:
continue
target = hermes_root / rel
# Security: reject absolute paths and traversals
try:
target.resolve().relative_to(hermes_root.resolve())
except ValueError:
errors.append(f"{rel}: path traversal blocked")
continue
try:
target.parent.mkdir(parents=True, exist_ok=True)
with zf.open(member) as src, open(target, "wb") as dst:
dst.write(src.read())
restored += 1
except (PermissionError, OSError) as exc:
errors.append(f"{rel}: {exc}")
return {
"ok": True,
"restored": restored,
"total": len(members),
"errors": errors[:20],
}
try:
result = await loop.run_in_executor(None, _do_restore)
except Exception as e:
logger.exception("Restore failed")
return web.json_response({"ok": False, "error": str(e)}, status=500)
return web.json_response(result)
def register_system_api_routes(app: web.Application) -> None:
"""Register system backup/restore API routes."""
app.router.add_get("/api/gui/system/backup", handle_system_backup)
app.router.add_post("/api/gui/system/restore", handle_system_restore)