From ca52dd8143b008e0dc02a381d89ae686043a971e Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Fri, 19 Jun 2026 18:50:57 +0530 Subject: [PATCH] feat(logging): opt-in HTTP/WS body capture to an isolated, share-excluded gui_bodies.log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stacked on #49003. That PR added always-on metadata (method/path/status/ latency + WS lifecycle) to the gui surface. This adds the heavy diagnostic tier — actual HTTP request bodies and PTY/WebSocket frames — for the hard dashboard/TUI bugs where metadata alone isn't enough. Body content can carry conversation data, so this is opt-in and built to be structurally incapable of leaking into a shared debug report (see #22016): - New config logging.capture_bodies (default false), surfaced in the dashboard / hermes tools config UI via _SCHEMA_OVERRIDES with a warning description. - When enabled, bodies go to a SEPARATE gui_bodies.log written by a dedicated logger (hermes_body_capture, propagate=False) that is deliberately NOT a member of any COMPONENT_PREFIXES. Four structural guarantees, all tested: 1. not under any component prefix -> never lands in gui.log / agent.log 2. not in hermes_cli/logs.py LOG_FILES -> not tailable via --- ~/.hermes/logs/agent.log (last 50) --- 2026-06-19 18:48:30,316 INFO [20260619_173001_f45949] agent.conversation_loop: API call #4: model=anthropic/claude-opus-4.8 provider=openrouter in=288583 out=529 total=289112 latency=10.6s cache=284912/288583 (99%) 2026-06-19 18:48:30,318 INFO [20260619_173001_f45949] agent.conversation_loop: Turn ended: reason=text_response(finish_reason=stop) model=anthropic/claude-opus-4.8 api_calls=4/16 budget=4/16 tool_turns=110 last_msg_role=assistant response_len=1474 session=20260619_173001_f45949 2026-06-19 18:48:30,325 INFO [20260619_173001_f45949] run_agent: OpenAI client closed (agent_close, shared=True, tcp_force_closed=0) thread=bg-review:6349795328 provider=openrouter base_url=https://openrouter.ai/api/v1 model=anthropic/claude-opus-4.8 2026-06-19 18:48:30,652 INFO run_agent: OpenAI client closed (stream_request_complete, shared=False, tcp_force_closed=0) thread=Thread-747 (_call):6421311488 provider=openrouter base_url=https://openrouter.ai/api/v1 model=anthropic/claude-opus-4.8 2026-06-19 18:48:30,653 INFO [20260619_153431_51fd01] agent.conversation_loop: API call #139: model=anthropic/claude-opus-4.8 provider=openrouter in=243960 out=991 total=244951 latency=11.5s cache=242196/243960 (99%) 2026-06-19 18:48:31,348 INFO [20260619_153431_51fd01] agent.tool_executor: tool terminal completed (0.69s, 161 chars) 2026-06-19 18:48:31,384 INFO run_agent: OpenAI client created (chat_completion_stream_request, shared=False) thread=Thread-749 (_call):6421311488 provider=openrouter base_url=https://openrouter.ai/api/v1 model=anthropic/claude-opus-4.8 2026-06-19 18:48:51,510 INFO run_agent: OpenAI client closed (stream_request_complete, shared=False, tcp_force_closed=0) thread=Thread-749 (_call):6421311488 provider=openrouter base_url=https://openrouter.ai/api/v1 model=anthropic/claude-opus-4.8 2026-06-19 18:48:51,511 INFO [20260619_153431_51fd01] agent.conversation_loop: API call #140: model=anthropic/claude-opus-4.8 provider=openrouter in=245038 out=1783 total=246821 latency=20.1s cache=243477/245038 (99%) 2026-06-19 18:48:52,215 INFO [20260619_153431_51fd01] agent.tool_executor: tool terminal completed (0.70s, 153 chars) 2026-06-19 18:48:52,245 INFO run_agent: OpenAI client created (chat_completion_stream_request, shared=False) thread=Thread-751 (_call):6421311488 provider=openrouter base_url=https://openrouter.ai/api/v1 model=anthropic/claude-opus-4.8 2026-06-19 18:48:59,489 INFO run_agent: OpenAI client closed (stream_request_complete, shared=False, tcp_force_closed=0) thread=Thread-751 (_call):6421311488 provider=openrouter base_url=https://openrouter.ai/api/v1 model=anthropic/claude-opus-4.8 2026-06-19 18:48:59,490 INFO [20260619_153431_51fd01] agent.conversation_loop: API call #141: model=anthropic/claude-opus-4.8 provider=openrouter in=246873 out=493 total=247366 latency=7.3s cache=244127/246873 (99%) 2026-06-19 18:49:13,666 INFO [20260619_153431_51fd01] agent.tool_executor: tool terminal completed (14.17s, 979 chars) 2026-06-19 18:49:13,692 INFO run_agent: OpenAI client created (chat_completion_stream_request, shared=False) thread=Thread-753 (_call):6421311488 provider=openrouter base_url=https://openrouter.ai/api/v1 model=anthropic/claude-opus-4.8 2026-06-19 18:49:22,930 INFO run_agent: OpenAI client closed (stream_request_complete, shared=False, tcp_force_closed=0) thread=Thread-753 (_call):6421311488 provider=openrouter base_url=https://openrouter.ai/api/v1 model=anthropic/claude-opus-4.8 2026-06-19 18:49:22,932 INFO [20260619_153431_51fd01] agent.conversation_loop: API call #142: model=anthropic/claude-opus-4.8 provider=openrouter in=247686 out=548 total=248234 latency=9.3s cache=245109/247686 (99%) 2026-06-19 18:49:23,254 INFO [20260619_153431_51fd01] agent.tool_executor: tool patch completed (0.10s, 1394 chars) 2026-06-19 18:49:23,287 INFO run_agent: OpenAI client created (chat_completion_stream_request, shared=False) thread=Thread-762 (_call):6421311488 provider=openrouter base_url=https://openrouter.ai/api/v1 model=anthropic/claude-opus-4.8 2026-06-19 18:49:26,661 INFO run_agent: OpenAI client closed (stream_request_complete, shared=False, tcp_force_closed=0) thread=Thread-762 (_call):6421311488 provider=openrouter base_url=https://openrouter.ai/api/v1 model=anthropic/claude-opus-4.8 2026-06-19 18:49:26,662 INFO [20260619_153431_51fd01] agent.conversation_loop: API call #143: model=anthropic/claude-opus-4.8 provider=openrouter in=248814 out=104 total=248918 latency=3.4s cache=246934/248814 (99%) 2026-06-19 18:49:27,958 INFO [20260619_153431_51fd01] agent.tool_executor: tool terminal completed (1.29s, 14487 chars) 2026-06-19 18:49:27,984 INFO run_agent: OpenAI client created (chat_completion_stream_request, shared=False) thread=Thread-764 (_call):6421311488 provider=openrouter base_url=https://openrouter.ai/api/v1 model=anthropic/claude-opus-4.8 2026-06-19 18:49:43,991 INFO run_agent: OpenAI client closed (stream_request_complete, shared=False, tcp_force_closed=0) thread=Thread-764 (_call):6421311488 provider=openrouter base_url=https://openrouter.ai/api/v1 model=anthropic/claude-opus-4.8 2026-06-19 18:49:43,992 INFO [20260619_153431_51fd01] agent.conversation_loop: API call #144: model=anthropic/claude-opus-4.8 provider=openrouter in=255375 out=938 total=256313 latency=16.0s cache=247771/255375 (97%) 2026-06-19 18:49:44,087 INFO [20260619_153431_51fd01] agent.conversation_loop: Turn ended: reason=text_response(finish_reason=stop) model=anthropic/claude-opus-4.8 api_calls=36/90 budget=31/90 tool_turns=129 last_msg_role=assistant response_len=2300 session=20260619_153431_51fd01 2026-06-19 18:49:44,112 INFO run_agent: OpenAI client created (agent_init, shared=True) thread=bg-review:6421311488 provider=openrouter base_url=https://openrouter.ai/api/v1 model=anthropic/claude-opus-4.8 2026-06-19 18:49:44,454 INFO [20260619_153431_51fd01] agent.turn_context: conversation turn: session=20260619_153431_51fd01 model=anthropic/claude-opus-4.8 provider=openrouter platform=cli history=310 msg='Review the conversation above and update the skill library. Be ACTIVE — most ses...' 2026-06-19 18:49:44,573 INFO run_agent: OpenAI client created (chat_completion_stream_request, shared=False) thread=Thread-765 (_call):6150942720 provider=openrouter base_url=https://openrouter.ai/api/v1 model=anthropic/claude-opus-4.8 2026-06-19 18:49:54,258 INFO run_agent: OpenAI client closed (stream_request_complete, shared=False, tcp_force_closed=0) thread=Thread-765 (_call):6150942720 provider=openrouter base_url=https://openrouter.ai/api/v1 model=anthropic/claude-opus-4.8 2026-06-19 18:49:54,259 INFO [20260619_153431_51fd01] agent.conversation_loop: API call #1: model=anthropic/claude-opus-4.8 provider=openrouter in=258322 out=423 total=258745 latency=9.8s cache=248822/258322 (96%) 2026-06-19 18:49:54,360 INFO [20260619_153431_51fd01] agent.tool_executor: tool skills_list completed (0.10s, 21152 chars) 2026-06-19 18:49:54,383 INFO run_agent: OpenAI client created (chat_completion_stream_request, shared=False) thread=Thread-766 (_call):6150942720 provider=openrouter base_url=https://openrouter.ai/api/v1 model=anthropic/claude-opus-4.8 2026-06-19 18:50:02,705 INFO run_agent: OpenAI client closed (stream_request_complete, shared=False, tcp_force_closed=0) thread=Thread-766 (_call):6150942720 provider=openrouter base_url=https://openrouter.ai/api/v1 model=anthropic/claude-opus-4.8 2026-06-19 18:50:02,706 INFO [20260619_153431_51fd01] agent.conversation_loop: API call #2: model=anthropic/claude-opus-4.8 provider=openrouter in=266258 out=313 total=266571 latency=8.3s cache=258320/266258 (97%) 2026-06-19 18:50:02,814 INFO [20260619_153431_51fd01] agent.tool_executor: tool skill_view completed (0.11s, 111769 chars) 2026-06-19 18:50:02,836 INFO [20260619_153431_51fd01] tools.tool_result_storage: Persisted large tool result: skill_view (toolu_01G7Zvw8ttjsUkomENppFu5T, 111769 chars -> /var/folders/p5/nqn3gs293rv3wtvf01pl9_vr0000gn/T/hermes-results/toolu_01G7Zvw8ttjsUkomENppFu5T.txt) 2026-06-19 18:50:02,861 INFO run_agent: OpenAI client created (chat_completion_stream_request, shared=False) thread=Thread-769 (_call):6150942720 provider=openrouter base_url=https://openrouter.ai/api/v1 model=anthropic/claude-opus-4.8 2026-06-19 18:50:12,687 INFO run_agent: OpenAI client closed (stream_request_complete, shared=False, tcp_force_closed=0) thread=Thread-769 (_call):6150942720 provider=openrouter base_url=https://openrouter.ai/api/v1 model=anthropic/claude-opus-4.8 2026-06-19 18:50:12,688 INFO [20260619_153431_51fd01] agent.conversation_loop: API call #3: model=anthropic/claude-opus-4.8 provider=openrouter in=267367 out=386 total=267753 latency=9.8s cache=258694/267367 (97%) 2026-06-19 18:50:12,749 INFO [20260619_153431_51fd01] agent.tool_executor: tool skill_view completed (0.06s, 22856 chars) 2026-06-19 18:50:12,776 INFO run_agent: OpenAI client created (chat_completion_stream_request, shared=False) thread=Thread-770 (_call):6150942720 provider=openrouter base_url=https://openrouter.ai/api/v1 model=anthropic/claude-opus-4.8 2026-06-19 18:50:18,989 INFO [20260619_153431_51fd01] agent.turn_context: conversation turn: session=20260619_153431_51fd01 model=anthropic/claude-opus-4.8 provider=openrouter platform=cli history=310 msg='yes' 2026-06-19 18:50:19,032 INFO run_agent: OpenAI client created (chat_completion_stream_request, shared=False) thread=Thread-772 (_call):12901707776 provider=openrouter base_url=https://openrouter.ai/api/v1 model=anthropic/claude-opus-4.8 2026-06-19 18:50:21,530 INFO run_agent: OpenAI client closed (stream_request_complete, shared=False, tcp_force_closed=0) thread=Thread-770 (_call):6150942720 provider=openrouter base_url=https://openrouter.ai/api/v1 model=anthropic/claude-opus-4.8 2026-06-19 18:50:21,531 INFO [20260619_153431_51fd01] agent.conversation_loop: API call #4: model=anthropic/claude-opus-4.8 provider=openrouter in=276591 out=415 total=277006 latency=8.8s cache=266515/276591 (96%) 2026-06-19 18:50:21,585 WARNING [20260619_153431_51fd01] agent.tool_executor: Tool skill_view returned error (0.05s): {"success": false, "error": "File 'references/stacked-feature-prs.md' not found in skill 'incremental-architecture-refactor'.", "available_files": {}, "hint": "Use one of the available file paths list 2026-06-19 18:50:21,613 INFO run_agent: OpenAI client created (chat_completion_stream_request, shared=False) thread=Thread-773 (_call):6150942720 provider=openrouter base_url=https://openrouter.ai/api/v1 model=anthropic/claude-opus-4.8 2026-06-19 18:50:36,019 INFO run_agent: OpenAI client closed (stream_request_complete, shared=False, tcp_force_closed=0) thread=Thread-772 (_call):12901707776 provider=openrouter base_url=https://openrouter.ai/api/v1 model=anthropic/claude-opus-4.8 2026-06-19 18:50:36,020 INFO [20260619_153431_51fd01] agent.conversation_loop: API call #145: model=anthropic/claude-opus-4.8 provider=openrouter in=256317 out=997 total=257314 latency=17.0s cache=256311/256317 (100%) 3. not in debug.py _capture_default_log_snapshots() -> NEVER uploaded by ⚠️ This will upload the following to a public paste service: • System info (OS, Python version, Hermes version, provider, which API keys are configured — NOT the actual keys) • Recent log lines (agent.log, errors.log, gateway.log, desktop.log — may contain conversation fragments and file paths) • Full agent.log, gateway.log, and desktop.log (up to 512 KB each — likely contains conversation content, tool outputs, and file paths) Pastes auto-delete after 6 hours. Collecting debug report... Uploading... Debug report uploaded: Report https://paste.rs/nnfZj (failed to upload: agent.log: Failed to upload to any paste service: paste.rs: HTTP Error 500: Internal Server Error dpaste.com: , gateway.log: Failed to upload to any paste service: paste.rs: HTTP Error 500: Internal Server Error dpaste.com: , desktop.log: Failed to upload to any paste service: paste.rs: HTTP Error 500: Internal Server Error dpaste.com: ) ⏱ Pastes will auto-delete in 6 hours. To delete now: hermes debug delete Share these links with the Hermes team for support. 4. still redacted via RedactingFormatter as defence-in-depth - Disabled state attaches a NullHandler and sets the level above CRITICAL, so _capture_body() is a cheap no-op (single isEnabledFor check) on the hot path. Captured bodies are truncated to 4096 bytes. request.body() is Starlette- cached, so reading it in the access middleware does not consume the stream for downstream handlers. Capture sites: HTTP request body (access middleware), PTY in/out frames. Tests (tests/test_hermes_logging.py::TestBodyCaptureOptIn): disabled-by-default creates no file and captures nothing; enabled writes to gui_bodies.log and the payload is ABSENT from gui.log; large bodies truncate; the body logger is isolated from every component; and the body file is excluded from both LOG_FILES and the debug-share snapshot set. --- hermes_cli/config.py | 6 ++ hermes_cli/web_server.py | 57 ++++++++++++++++ hermes_logging.py | 56 ++++++++++++++++ tests/test_hermes_logging.py | 92 ++++++++++++++++++++++++++ website/docs/reference/cli-commands.md | 1 + 5 files changed, 212 insertions(+) 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