mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
feat(logging): opt-in HTTP/WS body capture to an isolated, share-excluded gui_bodies.log
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: <urlopen error [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: certificate has expired (_ssl.c:1016)>, gateway.log: Failed to upload to any paste service: paste.rs: HTTP Error 500: Internal Server Error dpaste.com: <urlopen error [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: certificate has expired (_ssl.c:1016)>, desktop.log: Failed to upload to any paste service: paste.rs: HTTP Error 500: Internal Server Error dpaste.com: <urlopen error [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: certificate has expired (_ssl.c:1016)>) ⏱ Pastes will auto-delete in 6 hours. To delete now: hermes debug delete <url> 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.
This commit is contained in:
parent
d0964c5150
commit
ca52dd8143
5 changed files with 212 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue