diff --git a/hermes_cli/config.py b/hermes_cli/config.py index c81df25c03b..f3b55c15697 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -2359,6 +2359,12 @@ DEFAULT_CONFIG = { "level": "INFO", # Minimum level for agent.log: DEBUG, INFO, WARNING "max_size_mb": 5, # Max size per log file before rotation "backup_count": 3, # Number of rotated backup files to keep + # Opt-in deep dashboard/TUI debugging: capture HTTP request bodies and + # PTY/WebSocket frames to a SEPARATE gui_bodies.log. OFF by default. + # Bodies may contain conversation content; this log is isolated from + # gui.log and is NEVER uploaded by `hermes debug share`. Enable only + # while reproducing an issue. + "capture_bodies": False, }, # Remotely-hosted model catalog manifest. When enabled, the CLI fetches diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index f1f343d0d44..9efdc877851 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -483,6 +483,44 @@ def _new_request_id() -> str: return secrets.token_hex(4) +# Opt-in body-capture logger (OFF unless logging.capture_bodies=true). It is +# isolated from gui.log and excluded from debug share — see hermes_logging.py. +# The handler attached to it gates output (NullHandler + level above CRITICAL +# when disabled), so calling _capture_body() while disabled is a cheap no-op. +try: + from hermes_logging import BODY_CAPTURE_LOGGER as _BODY_LOGGER_NAME +except Exception: # pragma: no cover - logging module always present in practice + _BODY_LOGGER_NAME = "hermes_body_capture" +_body_log = logging.getLogger(_BODY_LOGGER_NAME) + +# Cap per-captured body so a large upload/stream can't bloat the log. +_BODY_CAPTURE_MAX = 4096 + + +def _capture_body(kind: str, rid, data) -> None: + """Record one request/response/frame body to the opt-in body log. + + No-op unless ``logging.capture_bodies`` is enabled (the logger level/handler + gate this without a config read on the hot path). Truncates to + ``_BODY_CAPTURE_MAX`` and relies on the logger's RedactingFormatter to scrub + secrets as defence-in-depth. Diagnostic-only — never raises into a request. + """ + if not _body_log.isEnabledFor(logging.INFO): + return + try: + if isinstance(data, (bytes, bytearray)): + text = bytes(data[:_BODY_CAPTURE_MAX]).decode("utf-8", "replace") + else: + text = str(data)[:_BODY_CAPTURE_MAX] + truncated = len(data) > _BODY_CAPTURE_MAX + _body_log.info( + "%s rid=%s len=%d%s body=%r", + kind, rid, len(data), " (truncated)" if truncated else "", text, + ) + except Exception: + pass + + @app.middleware("http") async def access_log_middleware(request: Request, call_next): import time as _time @@ -490,6 +528,13 @@ async def access_log_middleware(request: Request, call_next): rid = request.headers.get("x-request-id") or _new_request_id() request.state.rid = rid peer = request.client.host if request.client else "?" + # Opt-in request-body capture. request.body() is cached by Starlette, so + # reading it here does not consume the stream for downstream handlers. + if _body_log.isEnabledFor(logging.INFO): + try: + _capture_body("http.request", rid, await request.body()) + except Exception: + pass start = _time.monotonic() status = 500 response = None @@ -612,6 +657,16 @@ _SCHEMA_OVERRIDES: Dict[str, Dict[str, Any]] = { "description": "Log level for agent.log", "options": ["DEBUG", "INFO", "WARNING", "ERROR"], }, + "logging.capture_bodies": { + "type": "boolean", + "description": ( + "Capture HTTP request bodies and PTY/WebSocket frames to a " + "separate gui_bodies.log for deep dashboard/TUI debugging. OFF by " + "default. Bodies may contain conversation content — this log is " + "isolated from gui.log and is NEVER included in 'hermes debug " + "share'. Enable only while reproducing an issue." + ), + }, "agent.service_tier": { "type": "select", "description": "API service tier (OpenAI/Anthropic)", @@ -11201,6 +11256,7 @@ async def pty_ws(ws: WebSocket) -> None: try: await ws.send_bytes(chunk) _stats["bytes_out"] += len(chunk) + _capture_body("pty.out", peer, chunk) except Exception: _stats["reason"] = "send_failed" return @@ -11231,6 +11287,7 @@ async def pty_ws(ws: WebSocket) -> None: bridge.write(raw) _stats["bytes_in"] += len(raw) + _capture_body("pty.in", peer, raw) except WebSocketDisconnect: pass except Exception: diff --git a/hermes_logging.py b/hermes_logging.py index 18f49a8b862..fe2bc61f2b7 100644 --- a/hermes_logging.py +++ b/hermes_logging.py @@ -223,6 +223,12 @@ COMPONENT_PREFIXES = { ), } +# Dedicated logger for opt-in HTTP/WS body capture. Intentionally NOT a member +# of any COMPONENT_PREFIXES above and configured with propagate=False, so its +# records reach only the isolated gui_bodies.log handler — never gui.log, +# agent.log, or anything the diagnostic surfaces (debug share) read. +BODY_CAPTURE_LOGGER = "hermes_body_capture" + # --------------------------------------------------------------------------- # Main setup @@ -333,6 +339,35 @@ def setup_logging( log_filter=_ComponentFilter(COMPONENT_PREFIXES["gui"]), ) + # --- gui_bodies.log (opt-in HTTP/WS body capture) ------------------ + # OFF by default; enabled only when config.yaml ``logging.capture_bodies`` + # is true. This is the heavy diagnostic tier — request/response bodies + # and WS frames — so it is DELIBERATELY ISOLATED: + # * a dedicated logger (BODY_CAPTURE_LOGGER, propagate=False) that is + # NOT under any COMPONENT_PREFIXES, so bodies never leak into + # gui.log / agent.log; + # * a dedicated file that is NOT registered in hermes_cli/logs.py + # LOG_FILES and NOT in the debug-share snapshot set, so it can + # never be auto-uploaded by ``hermes debug share`` (see #22016). + # Still redacted via RedactingFormatter as defence-in-depth. + body_logger = logging.getLogger(BODY_CAPTURE_LOGGER) + body_logger.handlers.clear() + body_logger.propagate = False + if _read_capture_bodies_config(): + body_logger.setLevel(logging.INFO) + _add_rotating_handler( + body_logger, + log_dir / "gui_bodies.log", + level=logging.INFO, + max_bytes=10 * 1024 * 1024, + backup_count=3, + formatter=RedactingFormatter(_LOG_FORMAT), + ) + else: + # Disabled: swallow any emit so a stray call is a cheap no-op. + body_logger.setLevel(logging.CRITICAL + 1) + body_logger.addHandler(logging.NullHandler()) + if _logging_initialized and not force: return log_dir @@ -563,3 +598,24 @@ def _read_logging_config(): except Exception: pass return (None, None, None) + + +def _read_capture_bodies_config() -> bool: + """Best-effort read of ``logging.capture_bodies`` from config.yaml. + + Defaults to ``False`` — body capture is opt-in. This is the gate that keeps + the heavy diagnostic tier off unless an operator explicitly enables it + (``hermes config set logging.capture_bodies true`` or the dashboard toggle). + """ + try: + import yaml + config_path = get_config_path() + if config_path.exists(): + with open(config_path, "r", encoding="utf-8") as f: + cfg = yaml.safe_load(f) or {} + log_cfg = cfg.get("logging", {}) + if isinstance(log_cfg, dict): + return bool(log_cfg.get("capture_bodies", False)) + except Exception: + pass + return False diff --git a/tests/test_hermes_logging.py b/tests/test_hermes_logging.py index 0d1a17ab267..8b68d93e92a 100644 --- a/tests/test_hermes_logging.py +++ b/tests/test_hermes_logging.py @@ -1075,3 +1075,95 @@ class TestSafeStderr: logger.info("Session hygiene: 400 messages — auto-compressing") finally: logger.removeHandler(handler) + + +class TestBodyCaptureOptIn: + """Opt-in HTTP/WS body capture (logging.capture_bodies). + + The heavy diagnostic tier: OFF by default, isolated from gui.log, and + structurally excluded from `hermes debug share` (#22016). + """ + + def _home(self, tmp_path, monkeypatch, capture): + import yaml + home = tmp_path / ".hermes" + home.mkdir() + (home / "config.yaml").write_text( + yaml.safe_dump({"logging": {"capture_bodies": capture}}) + ) + monkeypatch.setenv("HERMES_HOME", str(home)) + return home + + def test_disabled_by_default_no_body_file_and_no_capture(self, tmp_path, monkeypatch): + import logging + import hermes_logging + from hermes_logging import BODY_CAPTURE_LOGGER + import hermes_cli.web_server as ws + + home = self._home(tmp_path, monkeypatch, False) + hermes_logging.setup_logging(hermes_home=home, mode="gui", force=True) + + ws._capture_body("http.request", "rid", b"secret-conversation-content") + for h in logging.getLogger(BODY_CAPTURE_LOGGER).handlers: + h.flush() + + assert not (home / "logs" / "gui_bodies.log").exists() + + def test_enabled_captures_to_isolated_file_not_gui_log(self, tmp_path, monkeypatch): + import logging + import hermes_logging + from hermes_logging import BODY_CAPTURE_LOGGER + import hermes_cli.web_server as ws + + home = self._home(tmp_path, monkeypatch, True) + hermes_logging.setup_logging(hermes_home=home, mode="gui", force=True) + + ws._capture_body("http.request", "rid", b"captured-body-payload") + for h in logging.getLogger(BODY_CAPTURE_LOGGER).handlers: + h.flush() + + body = (home / "logs" / "gui_bodies.log") + assert body.exists() + assert "captured-body-payload" in body.read_text() + + # Must never leak into the shared gui.log. + gui = home / "logs" / "gui.log" + gui_text = gui.read_text() if gui.exists() else "" + assert "captured-body-payload" not in gui_text + + def test_truncates_large_body(self, tmp_path, monkeypatch): + import logging + import hermes_logging + from hermes_logging import BODY_CAPTURE_LOGGER + import hermes_cli.web_server as ws + + home = self._home(tmp_path, monkeypatch, True) + hermes_logging.setup_logging(hermes_home=home, mode="gui", force=True) + + ws._capture_body("http.request", "rid", b"A" * (ws._BODY_CAPTURE_MAX * 3)) + for h in logging.getLogger(BODY_CAPTURE_LOGGER).handlers: + h.flush() + + text = (home / "logs" / "gui_bodies.log").read_text() + assert "(truncated)" in text + # Far fewer than the 3x-cap input bytes are written. + assert text.count("A") <= ws._BODY_CAPTURE_MAX + 10 + + def test_body_logger_is_isolated_from_all_components(self): + from hermes_logging import BODY_CAPTURE_LOGGER, COMPONENT_PREFIXES + + for comp, prefixes in COMPONENT_PREFIXES.items(): + assert not BODY_CAPTURE_LOGGER.startswith(tuple(prefixes)), \ + f"body logger must not be a member of component {comp!r}" + + def test_body_file_excluded_from_log_registry_and_debug_share(self): + # Structural #22016 guarantee: the body file is neither tailable via + # `hermes logs` nor uploadable via `hermes debug share`. + from hermes_cli.logs import LOG_FILES + from hermes_cli.debug import _capture_default_log_snapshots + + assert "gui_bodies" not in LOG_FILES + assert not any("bodies" in fname for fname in LOG_FILES.values()) + + snaps = _capture_default_log_snapshots(50) + assert "gui_bodies" not in snaps diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md index afac5a08642..8aed05ea04d 100644 --- a/website/docs/reference/cli-commands.md +++ b/website/docs/reference/cli-commands.md @@ -854,6 +854,7 @@ View, tail, and filter Hermes log files. All logs are stored in `~/.hermes/logs/ | `errors` | `errors.log` | Warnings and errors only — a filtered subset of agent.log | | `gateway` | `gateway.log` | Messaging gateway activity — platform connections, message dispatch, webhook events | | `gui` | `gui.log` | Dashboard / TUI-gateway / PTY-bridge / websocket events — HTTP access log (method, path, status, latency, request id) and WebSocket lifecycle (accept, reject reason, close reason, duration, byte/message counters) | +| _(opt-in)_ | `gui_bodies.log` | HTTP request bodies + PTY/WebSocket frames, captured only when `logging.capture_bodies: true` (off by default). Isolated from `gui.log` and **never** included in `hermes debug share`, since bodies can contain conversation content. Not tailable via `hermes logs`; read the file directly while reproducing an issue. | | `desktop` | `desktop.log` | Electron desktop app — boot, backend spawn output, and recent Python tracebacks | ### Options