Compare commits

..

1 Commits

Author SHA1 Message Date
Alexander Whitestone
c22cdcaa8e fix: add _validate_gateway_config tests and API_SERVER_KEY network binding warning
Some checks failed
Docker Build and Publish / build-and-push (pull_request) Has been skipped
Contributor Attribution Check / check-attribution (pull_request) Failing after 23s
Supply Chain Audit / Scan PR for supply chain risks (pull_request) Successful in 27s
Tests / e2e (pull_request) Successful in 1m51s
Tests / test (pull_request) Failing after 37m0s
Refs #892 - Gateway config debt: missing keys and broken fallbacks

Changes:
- Add `_is_network_accessible()` helper to gateway/config.py (avoids circular
  import with gateway.platforms.base which imports from gateway.config)
- Add API_SERVER_KEY warning in `_validate_gateway_config`: when the API server
  is enabled on a network-accessible address (0.0.0.0, public IP, hostname) but
  no key is configured, log a warning at config-load time so operators see the
  issue before any adapter initialisation runs
- Add `TestValidateGatewayConfig` in tests/gateway/test_config.py covering:
  - idle_minutes <= 0 and None are corrected to 1440 (default)
  - at_hour outside 0-23 is corrected to 4 (default)
  - Boundary hours 0 and 23 are accepted unchanged
  - Empty platform token triggers a warning log
  - Disabled platform with empty token produces no warning
  - API server on 0.0.0.0 without key logs a warning
  - API server on 127.0.0.1 without key is silent (loopback is allowed)
  - API server with a key set logs no warning regardless of bind address

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 02:18:02 -04:00
10 changed files with 206 additions and 974 deletions

View File

@@ -8,6 +8,7 @@ Handles loading and validating configuration for:
- Delivery preferences
"""
import ipaddress
import logging
import os
import json
@@ -679,6 +680,26 @@ def load_gateway_config() -> GatewayConfig:
return config
def _is_network_accessible(host: str) -> bool:
"""Return True if *host* would expose a server beyond the loopback interface.
Duplicates the logic in ``gateway.platforms.base.is_network_accessible``
without creating a circular import (base.py imports from this module).
"""
try:
addr = ipaddress.ip_address(host)
if addr.is_loopback:
return False
# ::ffff:127.x.x.x — Python's is_loopback returns False for
# IPv4-mapped loopback; unwrap and check the underlying IPv4.
if getattr(addr, "ipv4_mapped", None) and addr.ipv4_mapped.is_loopback:
return False
return True
except ValueError:
# Hostname: assume it could be network-accessible.
return True
def _validate_gateway_config(config: "GatewayConfig") -> None:
"""Validate and sanitize a loaded GatewayConfig in place.
@@ -747,6 +768,22 @@ def _validate_gateway_config(config: "GatewayConfig") -> None:
)
pconfig.enabled = False
# Warn when the API server is enabled on a network-accessible address
# without an auth key. The adapter will refuse to start anyway, but
# surfacing this at config-load time lets operators see the problem in
# the startup log before any platform adapter initialisation runs.
api_cfg = config.platforms.get(Platform.API_SERVER)
if api_cfg and api_cfg.enabled:
key = api_cfg.extra.get("key", "")
host = api_cfg.extra.get("host", "127.0.0.1")
if not key and _is_network_accessible(host):
logger.warning(
"API Server is enabled on %s but API_SERVER_KEY is not set. "
"The adapter will refuse to start on a network-accessible address. "
"Set API_SERVER_KEY or bind to 127.0.0.1 for local-only access.",
host,
)
def _apply_env_overrides(config: GatewayConfig) -> None:
"""Apply environment variable overrides to config."""

View File

@@ -2,11 +2,6 @@
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
@@ -2308,30 +2303,6 @@ 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:
@@ -2342,7 +2313,26 @@ 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._register_routes(self._app)
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)
# Start background sweep to clean up orphaned (unconsumed) run streams
sweep_task = asyncio.create_task(self._sweep_orphaned_runs())
try:

View File

@@ -1,194 +0,0 @@
"""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

@@ -1,68 +0,0 @@
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

@@ -10,6 +10,7 @@ from gateway.config import (
PlatformConfig,
SessionResetPolicy,
_apply_env_overrides,
_validate_gateway_config,
load_gateway_config,
)
@@ -294,3 +295,151 @@ class TestHomeChannelEnvOverrides:
home = config.platforms[platform].home_channel
assert home is not None, f"{platform.value}: home_channel should not be None"
assert (home.chat_id, home.name) == expected, platform.value
class TestValidateGatewayConfig:
"""Tests for _validate_gateway_config — in-place sanitisation of loaded config."""
# -- idle_minutes validation --
def test_idle_minutes_zero_is_corrected_to_default(self):
config = GatewayConfig()
config.default_reset_policy.idle_minutes = 0
_validate_gateway_config(config)
assert config.default_reset_policy.idle_minutes == 1440
def test_idle_minutes_negative_is_corrected_to_default(self):
config = GatewayConfig()
config.default_reset_policy.idle_minutes = -60
_validate_gateway_config(config)
assert config.default_reset_policy.idle_minutes == 1440
def test_idle_minutes_none_is_corrected_to_default(self):
config = GatewayConfig()
config.default_reset_policy.idle_minutes = None # type: ignore[assignment]
_validate_gateway_config(config)
assert config.default_reset_policy.idle_minutes == 1440
def test_valid_idle_minutes_is_unchanged(self):
config = GatewayConfig()
config.default_reset_policy.idle_minutes = 90
_validate_gateway_config(config)
assert config.default_reset_policy.idle_minutes == 90
# -- at_hour validation --
def test_at_hour_too_high_is_corrected_to_default(self):
config = GatewayConfig()
config.default_reset_policy.at_hour = 24
_validate_gateway_config(config)
assert config.default_reset_policy.at_hour == 4
def test_at_hour_negative_is_corrected_to_default(self):
config = GatewayConfig()
config.default_reset_policy.at_hour = -1
_validate_gateway_config(config)
assert config.default_reset_policy.at_hour == 4
def test_valid_at_hour_is_unchanged(self):
config = GatewayConfig()
config.default_reset_policy.at_hour = 3
_validate_gateway_config(config)
assert config.default_reset_policy.at_hour == 3
def test_at_hour_boundary_values_are_valid(self):
for valid_hour in (0, 23):
config = GatewayConfig()
config.default_reset_policy.at_hour = valid_hour
_validate_gateway_config(config)
assert config.default_reset_policy.at_hour == valid_hour
# -- empty-token warning (enabled platforms) --
def test_empty_string_token_logs_warning(self, caplog):
import logging
config = GatewayConfig(
platforms={
Platform.TELEGRAM: PlatformConfig(enabled=True, token=""),
}
)
with caplog.at_level(logging.WARNING, logger="gateway.config"):
_validate_gateway_config(config)
assert any(
"TELEGRAM_BOT_TOKEN" in r.message and "empty" in r.message
for r in caplog.records
)
def test_disabled_platform_with_empty_token_no_warning(self, caplog):
import logging
config = GatewayConfig(
platforms={
Platform.TELEGRAM: PlatformConfig(enabled=False, token=""),
}
)
with caplog.at_level(logging.WARNING, logger="gateway.config"):
_validate_gateway_config(config)
assert not any("TELEGRAM_BOT_TOKEN" in r.message for r in caplog.records)
# -- API Server key / binding warnings --
def test_api_server_network_binding_without_key_logs_warning(self, caplog):
import logging
config = GatewayConfig(
platforms={
Platform.API_SERVER: PlatformConfig(
enabled=True,
extra={"host": "0.0.0.0"},
),
}
)
with caplog.at_level(logging.WARNING, logger="gateway.config"):
_validate_gateway_config(config)
assert any(
"API_SERVER_KEY" in r.message for r in caplog.records
)
def test_api_server_loopback_without_key_no_warning(self, caplog):
import logging
config = GatewayConfig(
platforms={
Platform.API_SERVER: PlatformConfig(
enabled=True,
extra={"host": "127.0.0.1"},
),
}
)
with caplog.at_level(logging.WARNING, logger="gateway.config"):
_validate_gateway_config(config)
assert not any(
"API_SERVER_KEY" in r.message for r in caplog.records
)
def test_api_server_network_binding_with_key_no_warning(self, caplog):
import logging
config = GatewayConfig(
platforms={
Platform.API_SERVER: PlatformConfig(
enabled=True,
extra={"host": "0.0.0.0", "key": "sk-real-key-here"},
),
}
)
with caplog.at_level(logging.WARNING, logger="gateway.config"):
_validate_gateway_config(config)
assert not any(
"API_SERVER_KEY" in r.message for r in caplog.records
)
def test_api_server_default_loopback_without_key_no_warning(self, caplog):
"""API server with no explicit host defaults to 127.0.0.1 — no warning."""
import logging
config = GatewayConfig(
platforms={
Platform.API_SERVER: PlatformConfig(enabled=True),
}
)
with caplog.at_level(logging.WARNING, logger="gateway.config"):
_validate_gateway_config(config)
assert not any(
"API_SERVER_KEY" in r.message for r in caplog.records
)

View File

@@ -1,61 +0,0 @@
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

View File

@@ -1,322 +0,0 @@
"""
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,70 +2218,6 @@ 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,34 +44,6 @@ 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:

View File

@@ -1,207 +0,0 @@
"""
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}"