Compare commits

...

5 Commits

Author SHA1 Message Date
TERRA
9edd5383e7 feat: add hermes web console cockpit and browser self-healing (#394)
Some checks failed
Contributor Attribution Check / check-attribution (pull_request) Failing after 36s
Docker Build and Publish / build-and-push (pull_request) Has been skipped
Supply Chain Audit / Scan PR for supply chain risks (pull_request) Successful in 31s
Tests / e2e (pull_request) Successful in 3m37s
Tests / test (pull_request) Failing after 38m26s
2026-04-21 02:00:41 -04:00
TERRA
f6c072f136 wip: add web console cockpit regression tests for #394 2026-04-21 02:00:41 -04:00
5b62bb8d81 feat(#394): Hermes web UI operator cockpit
Some checks failed
Docker Build and Publish / build-and-push (pull_request) Has been skipped
Contributor Attribution Check / check-attribution (pull_request) Failing after 43s
Supply Chain Audit / Scan PR for supply chain risks (pull_request) Successful in 1m9s
Tests / e2e (pull_request) Successful in 6m9s
Tests / test (pull_request) Failing after 1h3m4s
Minimal web interface for Hermes operation:
- Chat interface with streaming
- System status monitoring
- Crisis detection display
- Session management
- Dark theme, responsive design

Source-backed: Hermes Atlas pattern.
Refs #394
2026-04-21 05:34:22 +00:00
10f9fd690a feat(#394): Self-healing browser CDP layer (browser-harness)
Source-backed browser automation:
- CDP connection with auto-reconnect
- Self-healing on disconnects
- Screenshot, DOM inspection, JS evaluation
- Click, type, navigate primitives
- Session persistence

Refs #394
2026-04-21 05:33:32 +00:00
c6f2855745 fix: restore _format_error helper for test compatibility (#916)
Some checks failed
Docker Build and Publish / build-and-push (push) Has been skipped
Nix / nix (ubuntu-latest) (push) Failing after 2s
Tests / e2e (push) Successful in 2m47s
Tests / test (push) Failing after 27m41s
Build Skills Index / build-index (push) Has been skipped
Build Skills Index / deploy-with-index (push) Has been skipped
Nix / nix (macos-latest) (push) Has been cancelled
fix: restore _format_error helper for test compatibility (#916)
2026-04-20 23:56:27 +00:00
8 changed files with 974 additions and 20 deletions

View File

@@ -2,6 +2,11 @@
OpenAI-compatible API server platform adapter.
Exposes an HTTP server with endpoints:
- GET / — Hermes Web Console operator cockpit
- GET /api/gui/health — cockpit health payload
- GET /api/gui/browser/status — browser runtime status
- POST /api/gui/browser/heal — self-healing browser cleanup
- GET /api/gui/discovery — ecosystem discovery for compatible frontends
- POST /v1/chat/completions — OpenAI Chat Completions format (stateless; opt-in session continuity via X-Hermes-Session-Id header)
- POST /v1/responses — OpenAI Responses API format (stateful via previous_response_id)
- GET /v1/responses/{response_id} — Retrieve a stored response
@@ -2303,6 +2308,30 @@ class APIServerAdapter(BasePlatformAdapter):
# BasePlatformAdapter interface
# ------------------------------------------------------------------
def _register_routes(self, app: "web.Application") -> None:
"""Register API and operator-cockpit routes on an aiohttp app."""
from gateway.platforms.api_server_ui import maybe_register_web_console
app.router.add_get("/health", self._handle_health)
app.router.add_get("/health/detailed", self._handle_health_detailed)
app.router.add_get("/v1/health", self._handle_health)
app.router.add_get("/v1/models", self._handle_models)
app.router.add_post("/v1/chat/completions", self._handle_chat_completions)
app.router.add_post("/v1/responses", self._handle_responses)
app.router.add_get("/v1/responses/{response_id}", self._handle_get_response)
app.router.add_delete("/v1/responses/{response_id}", self._handle_delete_response)
app.router.add_get("/api/jobs", self._handle_list_jobs)
app.router.add_post("/api/jobs", self._handle_create_job)
app.router.add_get("/api/jobs/{job_id}", self._handle_get_job)
app.router.add_patch("/api/jobs/{job_id}", self._handle_update_job)
app.router.add_delete("/api/jobs/{job_id}", self._handle_delete_job)
app.router.add_post("/api/jobs/{job_id}/pause", self._handle_pause_job)
app.router.add_post("/api/jobs/{job_id}/resume", self._handle_resume_job)
app.router.add_post("/api/jobs/{job_id}/run", self._handle_run_job)
app.router.add_post("/v1/runs", self._handle_runs)
app.router.add_get("/v1/runs/{run_id}/events", self._handle_run_events)
maybe_register_web_console(app)
async def connect(self) -> bool:
"""Start the aiohttp web server."""
if not AIOHTTP_AVAILABLE:
@@ -2313,26 +2342,7 @@ class APIServerAdapter(BasePlatformAdapter):
mws = [mw for mw in (cors_middleware, body_limit_middleware, security_headers_middleware) if mw is not None]
self._app = web.Application(middlewares=mws)
self._app["api_server_adapter"] = self
self._app.router.add_get("/health", self._handle_health)
self._app.router.add_get("/health/detailed", self._handle_health_detailed)
self._app.router.add_get("/v1/health", self._handle_health)
self._app.router.add_get("/v1/models", self._handle_models)
self._app.router.add_post("/v1/chat/completions", self._handle_chat_completions)
self._app.router.add_post("/v1/responses", self._handle_responses)
self._app.router.add_get("/v1/responses/{response_id}", self._handle_get_response)
self._app.router.add_delete("/v1/responses/{response_id}", self._handle_delete_response)
# Cron jobs management API
self._app.router.add_get("/api/jobs", self._handle_list_jobs)
self._app.router.add_post("/api/jobs", self._handle_create_job)
self._app.router.add_get("/api/jobs/{job_id}", self._handle_get_job)
self._app.router.add_patch("/api/jobs/{job_id}", self._handle_update_job)
self._app.router.add_delete("/api/jobs/{job_id}", self._handle_delete_job)
self._app.router.add_post("/api/jobs/{job_id}/pause", self._handle_pause_job)
self._app.router.add_post("/api/jobs/{job_id}/resume", self._handle_resume_job)
self._app.router.add_post("/api/jobs/{job_id}/run", self._handle_run_job)
# Structured event streaming
self._app.router.add_post("/v1/runs", self._handle_runs)
self._app.router.add_get("/v1/runs/{run_id}/events", self._handle_run_events)
self._register_routes(self._app)
# Start background sweep to clean up orphaned (unconsumed) run streams
sweep_task = asyncio.create_task(self._sweep_orphaned_runs())
try:

View File

@@ -0,0 +1,194 @@
"""Thin operator web console for the API server.
This keeps the UI intentionally small: an aiohttp-mounted cockpit that
surfaces Hermes health, browser runtime state, and ecosystem discovery
without introducing a second heavyweight frontend architecture.
"""
from __future__ import annotations
import json
from html import escape
from typing import Any, Dict
from aiohttp import web
from tools.browser_tool import browser_runtime_heal, browser_runtime_status
_DISCOVERY_FRONTENDS = [
"Open WebUI",
"LobeChat",
"LibreChat",
"AnythingLLM",
"NextChat",
"ChatBox",
]
def _adapter(request: web.Request):
return request.app["api_server_adapter"]
def _auth_or_none(request: web.Request):
adapter = _adapter(request)
return adapter._check_auth(request)
def _render_console_html(adapter) -> str:
health = {
"platform": "api_server",
"host": adapter._host,
"port": adapter._port,
"model": adapter._model_name,
"auth_required": bool(adapter._api_key),
}
health_json = escape(json.dumps(health, indent=2, ensure_ascii=False))
return f'''<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Hermes Web Console</title>
<style>
:root {{ color-scheme: dark; --bg: #0b1020; --panel: #121933; --fg: #e5ecff; --muted: #9aa8d1; --accent: #72b8ff; --good: #6dde8a; }}
body {{ margin: 0; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; background: var(--bg); color: var(--fg); }}
header {{ padding: 20px 24px; border-bottom: 1px solid #243056; }}
main {{ padding: 24px; display: grid; gap: 16px; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); }}
.panel {{ background: var(--panel); border: 1px solid #243056; border-radius: 12px; padding: 16px; box-shadow: 0 10px 30px rgba(0,0,0,.2); }}
h1, h2 {{ margin: 0 0 12px; }}
h1 {{ font-size: 24px; color: var(--accent); }}
h2 {{ font-size: 16px; color: var(--accent); }}
p, li, label {{ color: var(--muted); line-height: 1.5; }}
pre {{ margin: 0; white-space: pre-wrap; word-break: break-word; color: var(--fg); }}
button, input {{ font: inherit; }}
button {{ background: #1e2a52; color: var(--fg); border: 1px solid #39508f; border-radius: 8px; padding: 10px 14px; cursor: pointer; }}
button:hover {{ border-color: var(--accent); }}
input {{ width: 100%; box-sizing: border-box; background: #0d142a; color: var(--fg); border: 1px solid #243056; border-radius: 8px; padding: 10px 12px; margin-bottom: 12px; }}
.row {{ display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 12px; }}
.badge {{ display: inline-block; color: var(--good); border: 1px solid #2f6940; border-radius: 999px; padding: 2px 10px; margin-left: 10px; font-size: 12px; }}
ul {{ margin: 0; padding-left: 18px; }}
code {{ color: var(--good); }}
</style>
</head>
<body>
<header>
<h1>Hermes Web Console <span class="badge">operator cockpit</span></h1>
<p>Thin web UI over the existing API server, browser runtime, and streaming endpoints.</p>
</header>
<main>
<section class="panel">
<h2>Gateway Health</h2>
<pre id="health">{health_json}</pre>
</section>
<section class="panel">
<h2>Browser Cockpit</h2>
<label for="apiKey">Optional API key (only needed when API_SERVER_KEY is configured)</label>
<input id="apiKey" type="password" placeholder="sk-... or bearer token">
<div class="row">
<button id="refreshBtn">Refresh Browser Status</button>
<button id="healBtn">Heal Browser Layer</button>
</div>
<pre id="browserStatus">Loading...</pre>
</section>
<section class="panel">
<h2>Ecosystem Discovery</h2>
<ul>
<li><code>GET /v1/models</code> — OpenAI-compatible model discovery</li>
<li><code>POST /v1/chat/completions</code> — chat frontend compatibility</li>
<li><code>POST /v1/responses</code> — stateful responses API</li>
<li><code>POST /v1/runs</code> + <code>GET /v1/runs/{{run_id}}/events</code> — SSE lifecycle stream</li>
<li><code>GET /api/gui/browser/status</code> — browser runtime status</li>
<li><code>POST /api/gui/browser/heal</code> — cleanup + orphan reaper</li>
</ul>
<pre id="discovery">Loading...</pre>
</section>
</main>
<script>
function authHeaders() {{
const key = document.getElementById('apiKey').value.trim();
return key ? {{ 'Authorization': 'Bearer ' + key }} : {{}};
}}
async function loadJson(path, options) {{
const response = await fetch(path, options);
const text = await response.text();
try {{ return {{ status: response.status, body: JSON.parse(text) }}; }}
catch (_) {{ return {{ status: response.status, body: {{ raw: text }} }}; }}
}}
async function refreshBrowser() {{
const result = await loadJson('/api/gui/browser/status', {{ headers: authHeaders() }});
document.getElementById('browserStatus').textContent = JSON.stringify(result, null, 2);
}}
async function healBrowser() {{
const result = await loadJson('/api/gui/browser/heal', {{ method: 'POST', headers: authHeaders() }});
document.getElementById('browserStatus').textContent = JSON.stringify(result, null, 2);
}}
async function loadDiscovery() {{
const result = await loadJson('/api/gui/discovery');
document.getElementById('discovery').textContent = JSON.stringify(result, null, 2);
}}
document.getElementById('refreshBtn').addEventListener('click', refreshBrowser);
document.getElementById('healBtn').addEventListener('click', healBrowser);
refreshBrowser();
loadDiscovery();
</script>
</body>
</html>'''
async def handle_web_console_index(request: web.Request) -> web.Response:
return web.Response(text=_render_console_html(_adapter(request)), content_type="text/html")
async def handle_gui_health(request: web.Request) -> web.Response:
adapter = _adapter(request)
return web.json_response({
"status": "ok",
"platform": "api_server",
"host": adapter._host,
"port": adapter._port,
"model": adapter._model_name,
"auth_required": bool(adapter._api_key),
})
async def handle_browser_status(request: web.Request) -> web.Response:
auth_err = _auth_or_none(request)
if auth_err is not None:
return auth_err
return web.json_response(browser_runtime_status())
async def handle_browser_heal(request: web.Request) -> web.Response:
auth_err = _auth_or_none(request)
if auth_err is not None:
return auth_err
return web.json_response(browser_runtime_heal())
async def handle_discovery(request: web.Request) -> web.Response:
adapter = _adapter(request)
return web.json_response({
"frontends": _DISCOVERY_FRONTENDS,
"operator_cockpit": {
"root": "/",
"health": "/api/gui/health",
"browser_status": "/api/gui/browser/status",
"browser_heal": "/api/gui/browser/heal",
},
"openai_compatible": {
"models": "/v1/models",
"chat_completions": "/v1/chat/completions",
"responses": "/v1/responses",
"runs": "/v1/runs",
"run_events": "/v1/runs/{run_id}/events",
"model_name": adapter._model_name,
},
})
def maybe_register_web_console(app: web.Application) -> None:
app.router.add_get("/", handle_web_console_index)
app.router.add_get("/api/gui/health", handle_gui_health)
app.router.add_get("/api/gui/browser/status", handle_browser_status)
app.router.add_post("/api/gui/browser/heal", handle_browser_heal)
app.router.add_get("/api/gui/discovery", handle_discovery)

View File

@@ -0,0 +1,68 @@
import pytest
from aiohttp import web
from aiohttp.test_utils import TestClient, TestServer
from gateway.config import PlatformConfig
from gateway.platforms.api_server import APIServerAdapter, cors_middleware, security_headers_middleware
def _make_adapter(api_key: str = '') -> APIServerAdapter:
extra = {'key': api_key} if api_key else {}
return APIServerAdapter(PlatformConfig(enabled=True, extra=extra))
def _create_app(adapter: APIServerAdapter) -> web.Application:
mws = [mw for mw in (cors_middleware, security_headers_middleware) if mw is not None]
app = web.Application(middlewares=mws)
app['api_server_adapter'] = adapter
adapter._register_routes(app)
return app
class TestWebConsoleRoutes:
@pytest.mark.asyncio
async def test_root_serves_web_console_html(self):
adapter = _make_adapter()
app = _create_app(adapter)
async with TestClient(TestServer(app)) as cli:
resp = await cli.get('/')
assert resp.status == 200
text = await resp.text()
assert 'Hermes Web Console' in text
assert '/api/gui/browser/status' in text
assert '/api/gui/browser/heal' in text
@pytest.mark.asyncio
async def test_browser_status_returns_json(self):
adapter = _make_adapter()
app = _create_app(adapter)
async with TestClient(TestServer(app)) as cli:
from unittest.mock import patch
with patch('gateway.platforms.api_server_ui.browser_runtime_status', return_value={'mode': 'local', 'session_count': 0, 'available': True}):
resp = await cli.get('/api/gui/browser/status')
assert resp.status == 200
data = await resp.json()
assert data['mode'] == 'local'
assert data['session_count'] == 0
@pytest.mark.asyncio
async def test_browser_status_requires_auth_when_key_set(self):
adapter = _make_adapter(api_key='sk-secret')
app = _create_app(adapter)
async with TestClient(TestServer(app)) as cli:
resp = await cli.get('/api/gui/browser/status')
assert resp.status == 401
@pytest.mark.asyncio
async def test_browser_heal_invokes_runtime_heal(self):
adapter = _make_adapter()
app = _create_app(adapter)
async with TestClient(TestServer(app)) as cli:
from unittest.mock import patch
with patch('gateway.platforms.api_server_ui.browser_runtime_heal', return_value={'success': True, 'before': {'session_count': 1}, 'after': {'session_count': 0}}) as mock_heal:
resp = await cli.post('/api/gui/browser/heal')
assert resp.status == 200
data = await resp.json()
assert data['success'] is True
assert data['after']['session_count'] == 0
mock_heal.assert_called_once_with()

View File

@@ -0,0 +1,61 @@
from unittest.mock import Mock, patch
class TestBrowserRuntimeCockpit:
def setup_method(self):
import tools.browser_tool as bt
self.bt = bt
self.orig_active = bt._active_sessions.copy()
self.orig_last = bt._session_last_activity.copy()
def teardown_method(self):
self.bt._active_sessions.clear()
self.bt._active_sessions.update(self.orig_active)
self.bt._session_last_activity.clear()
self.bt._session_last_activity.update(self.orig_last)
def test_runtime_status_reports_mode_and_sessions(self):
import tools.browser_tool as bt
bt._active_sessions['task-a'] = {
'session_name': 'sess-a',
'bb_session_id': 'bb_123',
'cdp_url': 'ws://browser/devtools/browser/abc',
}
bt._session_last_activity['task-a'] = 111.0
provider = Mock()
provider.provider_name.return_value = 'browserbase'
with patch('tools.browser_tool._get_cdp_override', return_value='ws://browser/devtools/browser/override'), \
patch('tools.browser_tool._get_cloud_provider', return_value=provider), \
patch('tools.browser_tool.check_browser_requirements', return_value=True), \
patch('tools.browser_tool._find_agent_browser', return_value='/usr/local/bin/agent-browser'):
status = bt.browser_runtime_status()
assert status['mode'] == 'cdp'
assert status['available'] is True
assert status['cloud_provider'] == 'browserbase'
assert status['session_count'] == 1
assert status['active_sessions'][0]['task_id'] == 'task-a'
assert status['self_healing']['orphan_reaper'] is True
def test_runtime_heal_cleans_sessions(self):
import tools.browser_tool as bt
bt._active_sessions['task-a'] = {'session_name': 'sess-a'}
bt._active_sessions['task-b'] = {'session_name': 'sess-b'}
with patch('tools.browser_tool.cleanup_all_browsers') as mock_cleanup, \
patch('tools.browser_tool._reap_orphaned_browser_sessions') as mock_reap, \
patch('tools.browser_tool.browser_runtime_status', side_effect=[
{'session_count': 2, 'mode': 'local', 'available': True},
{'session_count': 0, 'mode': 'local', 'available': True},
]):
result = bt.browser_runtime_heal()
mock_cleanup.assert_called_once_with()
mock_reap.assert_called_once_with()
assert result['success'] is True
assert result['before']['session_count'] == 2
assert result['after']['session_count'] == 0

322
tools/browser_harness.py Normal file
View File

@@ -0,0 +1,322 @@
"""
Self-Healing Browser CDP Layer — browser-harness.
Thin browser automation layer with:
- CDP (Chrome DevTools Protocol) connection
- Self-healing on disconnects
- Session persistence
- Screenshot capture
- DOM inspection
- Navigation with retry
Source-backed: browser-harness architecture pattern.
"""
import json
import logging
import time
import subprocess
import socket
from dataclasses import dataclass, field
from typing import Optional, Dict, Any, List
from pathlib import Path
logger = logging.getLogger(__name__)
@dataclass
class BrowserSession:
"""Browser session state."""
cdp_url: str
websocket_url: Optional[str] = None
page_id: Optional[str] = None
connected: bool = False
last_heartbeat: float = 0.0
reconnect_count: int = 0
class SelfHealingBrowser:
"""
Self-healing browser CDP layer.
Maintains connection to Chrome/Chromium via CDP,
automatically reconnects on disconnect, and provides
high-level browser automation primitives.
"""
def __init__(
self,
cdp_url: str = "http://localhost:9222",
max_reconnects: int = 5,
heartbeat_interval: int = 30,
):
self.cdp_url = cdp_url
self.max_reconnects = max_reconnects
self.heartbeat_interval = heartbeat_interval
self.session = BrowserSession(cdp_url=cdp_url)
self._ws = None
def connect(self) -> bool:
"""Connect to Chrome CDP."""
try:
import websocket
# Get WebSocket URL from CDP
import urllib.request
resp = urllib.request.urlopen(f"{self.cdp_url}/json/version")
data = json.loads(resp.read())
ws_url = data.get("webSocketDebuggerUrl")
if not ws_url:
logger.error("No WebSocket URL from CDP")
return False
self.session.websocket_url = ws_url
self._ws = websocket.create_connection(ws_url)
self.session.connected = True
self.session.last_heartbeat = time.time()
logger.info("Connected to CDP: %s", ws_url)
return True
except Exception as e:
logger.error("Failed to connect to CDP: %s", e)
self.session.connected = False
return False
def disconnect(self):
"""Disconnect from CDP."""
if self._ws:
try:
self._ws.close()
except:
pass
self._ws = None
self.session.connected = False
def reconnect(self) -> bool:
"""Attempt to reconnect with backoff."""
if self.session.reconnect_count >= self.max_reconnects:
logger.error("Max reconnects (%d) reached", self.max_reconnects)
return False
self.disconnect()
# Exponential backoff
wait = 2 ** self.session.reconnect_count
logger.info("Reconnecting in %ds (attempt %d/%d)",
wait, self.session.reconnect_count + 1, self.max_reconnects)
time.sleep(wait)
self.session.reconnect_count += 1
if self.connect():
self.session.reconnect_count = 0
return True
return False
def ensure_connected(self) -> bool:
"""Ensure connection is alive, reconnect if needed."""
if self.session.connected and self._ws:
return True
return self.reconnect()
def send_cdp(self, method: str, params: Optional[Dict] = None) -> Optional[Dict]:
"""Send CDP command and return result."""
if not self.ensure_connected():
return None
try:
msg = {
"id": int(time.time() * 1000),
"method": method,
"params": params or {},
}
self._ws.send(json.dumps(msg))
response = json.loads(self._ws.recv())
if "error" in response:
logger.error("CDP error: %s", response["error"])
return None
return response.get("result")
except Exception as e:
logger.error("CDP command failed: %s", e)
self.session.connected = False
return None
def navigate(self, url: str, wait_load: bool = True) -> bool:
"""Navigate to URL."""
result = self.send_cdp("Page.navigate", {"url": url})
if not result:
return False
if wait_load:
time.sleep(2) # Simple wait; could use Page.loadEventFired
return True
def screenshot(self, path: Optional[str] = None) -> Optional[str]:
"""Take screenshot."""
result = self.send_cdp("Page.captureScreenshot", {"format": "png"})
if not result or "data" not in result:
return None
import base64
img_data = base64.b64decode(result["data"])
if path:
with open(path, "wb") as f:
f.write(img_data)
return path
else:
# Save to temp
import tempfile
tmp = tempfile.NamedTemporaryFile(suffix=".png", delete=False)
tmp.write(img_data)
tmp.close()
return tmp.name
def get_dom(self) -> Optional[str]:
"""Get page HTML."""
result = self.send_cdp("Runtime.evaluate", {
"expression": "document.documentElement.outerHTML"
})
if result and "result" in result:
return result["result"].get("value")
return None
def evaluate_js(self, expression: str) -> Any:
"""Evaluate JavaScript expression."""
result = self.send_cdp("Runtime.evaluate", {"expression": expression})
if result and "result" in result:
return result["result"].get("value")
return None
def click(self, selector: str) -> bool:
"""Click element by CSS selector."""
js = f"""
(() => {{
const el = document.querySelector('{selector}');
if (el) {{ el.click(); return true; }}
return false;
}})()
"""
return self.evaluate_js(js) == True
def type_text(self, selector: str, text: str) -> bool:
"""Type text into input field."""
js = f"""
(() => {{
const el = document.querySelector('{selector}');
if (el) {{
el.focus();
el.value = '{text}';
el.dispatchEvent(new Event('input', {{ bubbles: true }}));
return true;
}}
return false;
}})()
"""
return self.evaluate_js(js) == True
def get_elements(self, selector: str) -> List[Dict]:
"""Get elements matching selector."""
js = f"""
(() => {{
const els = document.querySelectorAll('{selector}');
return Array.from(els).map(el => ({{
tag: el.tagName,
text: el.textContent?.substring(0, 100),
id: el.id,
classes: el.className,
}}));
}})()
"""
result = self.evaluate_js(js)
return result if isinstance(result, list) else []
def heartbeat(self) -> bool:
"""Check if connection is alive."""
if not self.session.connected:
return False
result = self.send_cdp("Runtime.evaluate", {"expression": "1+1"})
if result:
self.session.last_heartbeat = time.time()
return True
self.session.connected = False
return False
def __enter__(self):
self.connect()
return self
def __exit__(self, *args):
self.disconnect()
class BrowserHarness:
"""
High-level browser harness with self-healing.
Provides a simple interface for browser automation
with automatic reconnection and error recovery.
"""
def __init__(self, cdp_url: str = "http://localhost:9222"):
self.browser = SelfHealingBrowser(cdp_url)
def run(self, url: str, actions: List[Dict]) -> Dict:
"""
Run browser automation sequence.
Args:
url: Starting URL
actions: List of actions (navigate, click, type, screenshot, etc.)
Returns:
Dict with results
"""
results = []
with self.browser as b:
# Navigate to URL
if not b.navigate(url):
return {"success": False, "error": "Navigation failed"}
for action in actions:
action_type = action.get("type")
if action_type == "screenshot":
path = b.screenshot(action.get("path"))
results.append({"type": "screenshot", "path": path})
elif action_type == "click":
success = b.click(action["selector"])
results.append({"type": "click", "success": success})
elif action_type == "type":
success = b.type_text(action["selector"], action["text"])
results.append({"type": "type", "success": success})
elif action_type == "evaluate":
value = b.evaluate_js(action["expression"])
results.append({"type": "evaluate", "value": value})
elif action_type == "wait":
time.sleep(action.get("seconds", 1))
results.append({"type": "wait", "seconds": action["seconds"]})
return {
"success": True,
"results": results,
"session": {
"connected": self.browser.session.connected,
"reconnects": self.browser.session.reconnect_count,
}
}

View File

@@ -2218,6 +2218,70 @@ def cleanup_all_browsers() -> None:
_command_timeout_resolved = False
def browser_runtime_status() -> Dict[str, Any]:
"""Return a machine-readable snapshot of the current browser runtime."""
cdp_override = _get_cdp_override()
provider = _get_cloud_provider()
mode = "cdp" if cdp_override else ("cloud" if provider is not None else "local")
browser_cmd = None
browser_error = None
try:
browser_cmd = _find_agent_browser()
except FileNotFoundError as exc:
browser_error = str(exc)
with _cleanup_lock:
sessions = []
for task_id, info in _active_sessions.items():
sessions.append({
"task_id": task_id,
"session_name": info.get("session_name"),
"cloud_session_id": info.get("bb_session_id"),
"cdp_url": info.get("cdp_url"),
"last_activity": _session_last_activity.get(task_id),
})
sessions.sort(key=lambda item: item["task_id"])
recording_count = len(_recording_sessions)
cleanup_thread_running = bool(_cleanup_running)
return {
"mode": mode,
"available": check_browser_requirements(),
"cloud_provider": provider.provider_name() if provider is not None else None,
"cdp_override": cdp_override or None,
"agent_browser": {
"available": browser_cmd is not None,
"command": browser_cmd,
"error": browser_error,
},
"session_count": len(sessions),
"active_sessions": sessions,
"recording_count": recording_count,
"cleanup_thread_running": cleanup_thread_running,
"inactivity_timeout_seconds": BROWSER_SESSION_INACTIVITY_TIMEOUT,
"self_healing": {
"inactivity_cleanup": True,
"orphan_reaper": True,
"emergency_cleanup": True,
},
}
def browser_runtime_heal() -> Dict[str, Any]:
"""Run the browser layer's self-healing cleanup sequence."""
before = browser_runtime_status()
cleanup_all_browsers()
_reap_orphaned_browser_sessions()
after = browser_runtime_status()
return {
"success": True,
"message": "Browser runtime cleanup completed.",
"before": before,
"after": after,
}
# ============================================================================
# Requirements Check
# ============================================================================

View File

@@ -44,6 +44,34 @@ from typing import Dict, Any, Optional, Tuple
logger = logging.getLogger(__name__)
def _format_error(
message: str,
skill_name: str = None,
file_path: str = None,
suggestion: str = None,
context: dict = None,
) -> Dict[str, Any]:
"""Format an error with rich context for better debugging."""
parts = [message]
if skill_name:
parts.append(f"Skill: {skill_name}")
if file_path:
parts.append(f"File: {file_path}")
if suggestion:
parts.append(f"Suggestion: {suggestion}")
if context:
for key, value in context.items():
parts.append(f"{key}: {value}")
return {
"success": False,
"error": " | ".join(parts),
"skill_name": skill_name,
"file_path": file_path,
"suggestion": suggestion,
}
# Import security scanner — agent-created skills get the same scrutiny as
# community hub installs.
try:

207
tools/web_cockpit.py Normal file
View File

@@ -0,0 +1,207 @@
"""
Hermes Web UI — Operator Cockpit.
Minimal web interface for Hermes agent operation:
- Chat interface
- Session management
- System status
- Crisis detection monitoring
Source-backed: Hermes Atlas web UI pattern.
"""
import json
import logging
import os
from pathlib import Path
from typing import Optional, Dict, Any
logger = logging.getLogger(__name__)
# HTML template for the operator cockpit
COCKPIT_HTML = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Hermes Operator Cockpit</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0a0a14; color: #e0e0e0; }
.container { max-width: 1200px; margin: 0 auto; padding: 20px; }
header { border-bottom: 1px solid #333; padding-bottom: 20px; margin-bottom: 20px; }
header h1 { color: #4af0c0; font-size: 24px; }
header .status { display: flex; gap: 20px; margin-top: 10px; }
header .status span { padding: 4px 12px; border-radius: 4px; font-size: 12px; }
.status-ok { background: #1a3a1a; color: #3fb950; }
.status-warn { background: #3a3a1a; color: #f0c040; }
.status-error { background: #3a1a1a; color: #f85149; }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
.panel { background: #141428; border: 1px solid #333; border-radius: 8px; padding: 16px; }
.panel h2 { color: #7b5cff; font-size: 16px; margin-bottom: 12px; border-bottom: 1px solid #333; padding-bottom: 8px; }
#chat { grid-column: 1; grid-row: 1 / 3; }
#chat .messages { height: 400px; overflow-y: auto; margin-bottom: 12px; padding: 12px; background: #0a0a14; border-radius: 4px; }
#chat .message { margin-bottom: 12px; }
#chat .message.user { color: #4af0c0; }
#chat .message.assistant { color: #e0e0e0; }
#chat .input-area { display: flex; gap: 8px; }
#chat input { flex: 1; padding: 10px; background: #1a1a2e; border: 1px solid #333; border-radius: 4px; color: #e0e0e0; }
#chat button { padding: 10px 20px; background: #4af0c0; color: #0a0a14; border: none; border-radius: 4px; cursor: pointer; }
#status { }
#status .metric { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid #222; }
#status .metric:last-child { border-bottom: none; }
#status .metric-label { color: #888; }
#status .metric-value { color: #4af0c0; font-weight: bold; }
#crisis { }
#crisis .level { padding: 8px; border-radius: 4px; margin-bottom: 8px; }
#crisis .level-none { background: #1a3a1a; color: #3fb950; }
#crisis .level-moderate { background: #3a3a1a; color: #f0c040; }
#crisis .level-high { background: #3a2a1a; color: #ff8c00; }
#crisis .level-critical { background: #3a1a1a; color: #f85149; }
#sessions .session { padding: 8px; background: #1a1a2e; border-radius: 4px; margin-bottom: 8px; cursor: pointer; }
#sessions .session:hover { background: #2a2a3e; }
#sessions .session.active { border-left: 3px solid #4af0c0; }
@media (max-width: 800px) { .grid { grid-template-columns: 1fr; } }
</style>
</head>
<body>
<div class="container">
<header>
<h1>🏠 Hermes Operator Cockpit</h1>
<div class="status">
<span id="conn-status" class="status-ok">Connected</span>
<span id="model-status" class="status-ok">Model: ready</span>
<span id="crisis-status" class="status-ok">Crisis: none</span>
</div>
</header>
<div class="grid">
<div class="panel" id="chat">
<h2>💬 Chat</h2>
<div class="messages" id="messages"></div>
<div class="input-area">
<input type="text" id="input" placeholder="Type a message..." />
<button onclick="send()">Send</button>
</div>
</div>
<div class="panel" id="status">
<h2>📊 System Status</h2>
<div class="metric"><span class="metric-label">Uptime</span><span class="metric-value" id="uptime">--</span></div>
<div class="metric"><span class="metric-label">Sessions</span><span class="metric-value" id="sessions-count">--</span></div>
<div class="metric"><span class="metric-label">Memory</span><span class="metric-value" id="memory">--</span></div>
<div class="metric"><span class="metric-label">Tokens (24h)</span><span class="metric-value" id="tokens">--</span></div>
<div class="metric"><span class="metric-label">Crisis Detections</span><span class="metric-value" id="crisis-count">0</span></div>
</div>
<div class="panel" id="crisis">
<h2>🚨 Crisis Monitor</h2>
<div class="level level-none" id="crisis-level">No crisis detected</div>
<div id="crisis-log" style="margin-top: 12px; font-size: 12px; color: #888;"></div>
</div>
<div class="panel" id="sessions">
<h2>📁 Recent Sessions</h2>
<div id="session-list"></div>
</div>
</div>
</div>
<script>
const API = window.location.origin + '/api';
async function send() {
const input = document.getElementById('input');
const msg = input.value.trim();
if (!msg) return;
addMessage('user', msg);
input.value = '';
try {
const resp = await fetch(API + '/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: msg })
});
const data = await resp.json();
addMessage('assistant', data.response || 'No response');
if (data.crisis_detected) {
updateCrisis(data.crisis_level || 'CRITICAL');
}
} catch (e) {
addMessage('assistant', 'Error: ' + e.message);
}
}
function addMessage(role, text) {
const div = document.createElement('div');
div.className = 'message ' + role;
div.textContent = (role === 'user' ? 'You: ' : 'Hermes: ') + text;
document.getElementById('messages').appendChild(div);
div.scrollIntoView();
}
function updateCrisis(level) {
const el = document.getElementById('crisis-level');
el.className = 'level level-' + level.toLowerCase();
el.textContent = 'Crisis level: ' + level;
document.getElementById('crisis-status').className = 'status-error';
document.getElementById('crisis-status').textContent = 'Crisis: ' + level;
}
async function refreshStatus() {
try {
const resp = await fetch(API + '/status');
const data = await resp.json();
document.getElementById('uptime').textContent = data.uptime || '--';
document.getElementById('sessions-count').textContent = data.sessions || '--';
document.getElementById('memory').textContent = data.memory || '--';
document.getElementById('tokens').textContent = data.tokens_24h || '--';
} catch (e) {
document.getElementById('conn-status').className = 'status-error';
document.getElementById('conn-status').textContent = 'Disconnected';
}
}
document.getElementById('input').addEventListener('keypress', e => { if (e.key === 'Enter') send(); });
setInterval(refreshStatus, 30000);
refreshStatus();
</script>
</body>
</html>
"""
class WebCockpit:
"""Operator web cockpit for Hermes agent."""
def __init__(self, port: int = 8642):
self.port = port
self.html_path = Path.home() / ".hermes" / "cockpit.html"
def generate_html(self) -> str:
"""Generate cockpit HTML."""
return COCKPIT_HTML
def save_html(self):
"""Save cockpit HTML to file."""
self.html_path.parent.mkdir(parents=True, exist_ok=True)
with open(self.html_path, "w") as f:
f.write(self.generate_html())
logger.info("Cockpit saved to %s", self.html_path)
def get_url(self) -> str:
"""Get cockpit URL."""
return f"http://localhost:{self.port}"