""" Hermes Web UI -- Main server entry point. Thin routing shell: imports Handler, delegates to api/routes.py, runs server. All business logic lives in api/*. """ import logging import socket import sys import time import traceback from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from urllib.parse import urlparse logger = logging.getLogger(__name__) from api.auth import check_auth from api.config import HOST, PORT, STATE_DIR, SESSION_DIR, DEFAULT_WORKSPACE from api.helpers import j from api.routes import handle_get, handle_post, handle_put, handle_delete from api.startup import auto_install_agent_deps, fix_credential_permissions class QuietHTTPServer(ThreadingHTTPServer): """Custom HTTP server that silently handles common network errors.""" def handle_error(self, request, client_address): """Override to suppress logging for common client disconnect errors.""" exc_type, exc_value, _ = sys.exc_info() # Silently ignore common connection errors caused by client disconnects if exc_type in (ConnectionResetError, BrokenPipeError, ConnectionAbortedError): return # Also handle socket errors that indicate client disconnect if exc_type is socket.error: # errno 54 is Connection reset by peer on macOS/BSD # errno 104 is Connection reset by peer on Linux if exc_value.errno in (54, 104, 32): # ECONNRESET, EPIPE return # For other errors, use default logging super().handle_error(request, client_address) class Handler(BaseHTTPRequestHandler): timeout = 30 # seconds — kills idle/incomplete connections to prevent thread exhaustion server_version = 'HermesWebUI/0.50.38' def log_message(self, fmt, *args): pass # suppress default Apache-style log def log_request(self, code: str='-', size: str='-') -> None: """Structured JSON logs for each request.""" import json as _json duration_ms = round((time.time() - getattr(self, '_req_t0', time.time())) * 1000, 1) record = _json.dumps({ 'ts': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()), 'method': self.command or '-', 'path': self.path or '-', 'status': int(code) if str(code).isdigit() else code, 'ms': duration_ms, }) print(f'[webui] {record}', flush=True) def do_OPTIONS(self) -> None: """Handle CORS preflight requests.""" self._req_t0 = time.time() try: parsed = urlparse(self.path) origin = self.headers.get('Origin', '') # Set CORS headers for preflight self.send_response(204) self.send_header('Access-Control-Allow-Origin', origin or '*') self.send_header('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE,PATCH,OPTIONS') self.send_header('Access-Control-Allow-Headers', 'Content-Type,Authorization,X-Requested-With') self.send_header('Access-Control-Max-Age', '86400') self.send_header('Vary', 'Origin') self.send_header('X-Content-Type-Options', 'nosniff') self.send_header('X-Frame-Options', 'DENY') self.end_headers() except Exception as e: print(f'[webui] ERROR OPTIONS {self.path}\n' + traceback.format_exc(), flush=True) self.send_response(500) self.end_headers() def do_GET(self) -> None: self._req_t0 = time.time() try: parsed = urlparse(self.path) if not check_auth(self, parsed): return result = handle_get(self, parsed) if result is False: return j(self, {'error': 'not found'}, status=404) except Exception as e: print(f'[webui] ERROR {self.command} {self.path}\n' + traceback.format_exc(), flush=True) return j(self, {'error': 'Internal server error'}, status=500) def do_POST(self) -> None: self._req_t0 = time.time() try: parsed = urlparse(self.path) if not check_auth(self, parsed): return result = handle_post(self, parsed) if result is False: return j(self, {'error': 'not found'}, status=404) except Exception as e: print(f'[webui] ERROR {self.command} {self.path}\n' + traceback.format_exc(), flush=True) return j(self, {'error': 'Internal server error'}, status=500) def do_PUT(self) -> None: self._req_t0 = time.time() try: parsed = urlparse(self.path) if not check_auth(self, parsed): return result = handle_put(self, parsed) if result is False: return j(self, {'error': 'not found'}, status=404) except Exception as e: print(f'[webui] ERROR {self.command} {self.path}\n' + traceback.format_exc(), flush=True) return j(self, {'error': 'Internal server error'}, status=500) def do_DELETE(self) -> None: self._req_t0 = time.time() try: parsed = urlparse(self.path) if not check_auth(self, parsed): return result = handle_delete(self, parsed) if result is False: return j(self, {'error': 'not found'}, status=404) except Exception as e: print(f'[webui] ERROR {self.command} {self.path}\n' + traceback.format_exc(), flush=True) return j(self, {'error': 'Internal server error'}, status=500) def main() -> None: # Load ~/.hermes/.env into os.environ so API keys are available # (mirrors what run_agent.py does via load_hermes_dotenv). from pathlib import Path as _P import os as _os _env_file = _P(_os.environ.get('HERMES_HOME', str(_P.home() / '.hermes'))) / '.env' if _env_file.exists(): try: with open(_env_file) as _f: for _line in _f: _line = _line.strip() if _line and not _line.startswith('#') and '=' in _line: _k, _v = _line.split('=', 1) _k = _k.strip() _v = _v.strip().strip('"').strip("'") if _k and _k not in _os.environ: _os.environ[_k] = _v except Exception: pass from api.config import print_startup_config, verify_hermes_imports, _HERMES_FOUND print_startup_config() # Fix sensitive file permissions before doing anything else fix_credential_permissions() within_container = False # Check for the "/.within_container" file to determine if we're running inside a container; this file is created in the Dockerfile try: with open('/.within_container', 'r') as f: within_container = True except FileNotFoundError: pass if within_container: print('[ok] Running within container.', flush=True) # Security: warn if binding non-loopback without authentication from api.auth import is_auth_enabled if HOST not in ('127.0.0.1', '::1', 'localhost') and not is_auth_enabled(): print(f'[!!] WARNING: Binding to {HOST} with NO PASSWORD SET.', flush=True) print(f' Anyone on the network can access your filesystem and agent.', flush=True) print(f' Set a password via Settings or HERMES_WEBUI_PASSWORD env var.', flush=True) print(f' To suppress: bind to 127.0.0.1 or set a password.', flush=True) if within_container: print(f' Note: You are running within a container, must bind to 0.0.0.0 to publish the port.', flush=True) elif not is_auth_enabled(): print(f' [tip] No password set. Any process on this machine can read sessions', flush=True) print(f' and memory via the local API. Set HERMES_WEBUI_PASSWORD to', flush=True) print(f' enable authentication.', flush=True) ok, missing, errors = verify_hermes_imports() if not ok and _HERMES_FOUND: print(f'[!!] Warning: Hermes agent found but missing modules: {missing}', flush=True) for mod, err in errors.items(): print(f' {mod}: {err}', flush=True) print(' Attempting to install missing dependencies from agent requirements.txt...', flush=True) auto_install_agent_deps() ok, missing, errors = verify_hermes_imports() if not ok: print(f'[!!] Still missing after install attempt: {missing}', flush=True) for mod, err in errors.items(): print(f' {mod}: {err}', flush=True) print(' Agent features may not work correctly.', flush=True) else: print('[ok] Agent dependencies installed successfully.', flush=True) STATE_DIR.mkdir(parents=True, exist_ok=True) SESSION_DIR.mkdir(parents=True, exist_ok=True) DEFAULT_WORKSPACE.mkdir(parents=True, exist_ok=True) # Start the gateway session watcher for real-time SSE updates try: from api.gateway_watcher import start_watcher start_watcher() except Exception as e: print(f'[!!] WARNING: Gateway watcher failed to start: {e}', flush=True) httpd = QuietHTTPServer((HOST, PORT), Handler) # ── TLS/HTTPS setup (optional) ───────────────────────────────────────── from api.config import TLS_ENABLED, TLS_CERT, TLS_KEY scheme = 'https' if TLS_ENABLED else 'http' if TLS_ENABLED: try: import ssl ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) ctx.minimum_version = ssl.TLSVersion.TLSv1_2 ctx.load_cert_chain(TLS_CERT, TLS_KEY) httpd.socket = ctx.wrap_socket(httpd.socket, server_side=True) print(f' TLS enabled: cert={TLS_CERT}, key={TLS_KEY}', flush=True) except Exception as e: print(f'[!!] WARNING: TLS setup failed ({e}), falling back to HTTP', flush=True) scheme = 'http' print(f' Hermes Web UI listening on {scheme}://{HOST}:{PORT}', flush=True) if HOST == '127.0.0.1' or within_container: print(f' Remote access: ssh -N -L {PORT}:127.0.0.1:{PORT} @', flush=True) print(f' Then open: {scheme}://localhost:{PORT}', flush=True) print('', flush=True) try: httpd.serve_forever() finally: # Stop the gateway watcher on shutdown try: from api.gateway_watcher import stop_watcher stop_watcher() except Exception: logger.debug("Failed to stop gateway watcher during shutdown") if __name__ == '__main__': main()