Skip to content

Commit 0950b08

Browse files
authored
feat(dashboard): add Debug Share to the System page (NousResearch#38600)
* Port from google-gemini/gemini-cli#21541: back up corrupted config.yaml When config.yaml fails to parse, load_config() silently falls back to DEFAULT_CONFIG and leaves the broken file on disk. If the user then re-runs the setup wizard or hermes config set (both rewrite config.yaml), their broken-but-recoverable overrides are lost for good. Adapts the policy-file recovery from gemini-cli#21541: on the first parse warning for a given broken file, snapshot it to config.yaml.corrupt.<ts>.bak (best-effort, symlink-guarded, size-deduped) and tell the user where it landed. Unlike Gemini's version we deliberately do NOT reset config.yaml to a clean state — hermes never silently mutates user config, and leaving it means a hand-fixed file is re-read on the next load. Tests: 3 new cases (backup created + content preserved + original untouched; same-size backup dedup; symlink not copied). E2E verified with isolated HERMES_HOME and a real tab-indented broken config. * feat(dashboard): add Debug Share to the System page Surface `hermes debug share` in the dashboard. The System > Operations section gets a dedicated card that uploads a redacted report + full logs and returns the paste URLs as real, copyable links instead of a log tail. - debug.py: factor a pure build_debug_share() returning structured {urls, failures, redacted, auto_delete_seconds}; run_debug_share now calls it (CLI output unchanged). - web_server.py: POST /api/ops/debug-share runs the share core in a worker thread and returns the structured payload synchronously (the URLs are the whole point — not a backgrounded action). - api.ts: runDebugShare() + DebugShareResponse. - SystemPage.tsx: share card with a redaction toggle (on by default), per-link + copy-all buttons, and the 6h auto-delete countdown. - tests: build_debug_share core + endpoint (redact toggle, failure 502, token gate).
1 parent 506ac95 commit 0950b08

8 files changed

Lines changed: 689 additions & 58 deletions

File tree

‎hermes_cli/config.py‎

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,13 @@
1717
import os
1818
import platform
1919
import re
20+
import shutil
2021
import stat
2122
import subprocess
2223
import sys
2324
import tempfile
2425
import threading
26+
import time
2527
from dataclasses import dataclass
2628
from pathlib import Path
2729
from typing import Dict, Any, Optional, List, Tuple
@@ -36,6 +38,60 @@
3638
_CONFIG_PARSE_WARNED: set = set()
3739

3840

41+
def _backup_corrupt_config(config_path: Path) -> Optional[Path]:
42+
"""Preserve a corrupted ``config.yaml`` by copying it to a timestamped ``.bak``.
43+
44+
When the YAML can't be parsed, ``load_config()`` silently falls back to
45+
``DEFAULT_CONFIG`` and the user's broken file stays on disk untouched.
46+
That file is still the user's only copy of their intended overrides — if
47+
they re-run the setup wizard or ``hermes config set`` (which rewrites
48+
``config.yaml``), the broken-but-recoverable content is gone for good.
49+
50+
This snapshots the corrupted file to ``config.yaml.corrupt.<ts>.bak`` so
51+
the user can diff/repair it. Unlike Gemini CLI's policy-file recovery
52+
(which resets the live file to a clean state), we deliberately leave
53+
``config.yaml`` in place: hermes never silently mutates the user's config,
54+
and leaving it means a hand-fixed file is re-read on the next load. The
55+
backup is best-effort — any failure (permissions, symlink, disk full) is
56+
swallowed so config loading is never blocked by backup problems.
57+
58+
Returns the backup path on success, else ``None``. Symlinks are not
59+
followed/copied (mirrors the Gemini #21541 lstat guard) to avoid
60+
clobbering whatever a malicious/misconfigured symlink points at.
61+
"""
62+
try:
63+
if config_path.is_symlink():
64+
return None
65+
st = config_path.stat()
66+
if st.st_size == 0:
67+
# Empty file isn't worth preserving and yaml.safe_load returns {}
68+
# for it anyway (so it wouldn't reach here), but guard regardless.
69+
return None
70+
ts = time.strftime("%Y%m%d-%H%M%S")
71+
backup_path = config_path.with_name(f"{config_path.name}.corrupt.{ts}.bak")
72+
# Don't clobber an existing backup from the same second; if there's
73+
# already a corrupt backup for this exact mtime, assume we've snapshotted
74+
# this corruption already and skip (the dedup cache normally prevents a
75+
# second call, but a process restart can clear it).
76+
sibling_baks = list(
77+
config_path.parent.glob(f"{config_path.name}.corrupt.*.bak")
78+
)
79+
for existing in sibling_baks:
80+
try:
81+
if existing.stat().st_size == st.st_size:
82+
# Same size as the current broken file — likely the same
83+
# corruption already preserved. Avoid backup churn.
84+
return None
85+
except OSError:
86+
continue
87+
if backup_path.exists():
88+
return None
89+
shutil.copy2(config_path, backup_path)
90+
return backup_path
91+
except Exception:
92+
return None
93+
94+
3995
def _warn_config_parse_failure(config_path: Path, exc: Exception) -> None:
4096
"""Surface a config.yaml parse failure to user, log, and stderr.
4197
@@ -48,7 +104,11 @@ def _warn_config_parse_failure(config_path: Path, exc: Exception) -> None:
48104
Now: warn once per (path, mtime_ns, size) on stderr **and** in
49105
``agent.log`` / ``errors.log`` at WARNING level so ``hermes logs``
50106
surfaces it. Re-warns automatically if the file changes (different
51-
mtime/size), so users editing the config see the next failure.
107+
mtime/size), so users editing the config see the next failure. On the
108+
first warning for a given broken file we also snapshot it to a
109+
timestamped ``.bak`` (best-effort) so the user's recoverable content
110+
survives any later rewrite of ``config.yaml`` by the setup wizard or
111+
``hermes config set``.
52112
"""
53113
try:
54114
st = config_path.stat()
@@ -59,12 +119,16 @@ def _warn_config_parse_failure(config_path: Path, exc: Exception) -> None:
59119
return
60120
_CONFIG_PARSE_WARNED.add(key)
61121

122+
backup_path = _backup_corrupt_config(config_path)
123+
62124
msg = (
63125
f"Failed to parse {config_path}: {exc}. "
64126
f"Falling back to default config — every user override "
65127
f"(auxiliary providers, fallback chain, model settings) is being IGNORED. "
66128
f"Fix the YAML and restart."
67129
)
130+
if backup_path is not None:
131+
msg += f" A copy of the corrupted file was saved to {backup_path}."
68132
logger.warning(msg)
69133
try:
70134
sys.stderr.write(f"⚠️ hermes config: {msg}\n")

‎hermes_cli/debug.py‎

Lines changed: 119 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -585,19 +585,40 @@ def collect_debug_report(
585585
# CLI entry points
586586
# ---------------------------------------------------------------------------
587587

588-
def run_debug_share(args):
589-
"""Collect debug report + full logs, upload each, print URLs."""
590-
_best_effort_sweep_expired_pastes()
588+
@dataclass
589+
class DebugShareResult:
590+
"""Structured outcome of a ``debug share`` upload.
591591
592-
log_lines = getattr(args, "lines", 200)
593-
expiry = getattr(args, "expire", 7)
594-
local_only = getattr(args, "local", False)
595-
redact = not getattr(args, "no_redact", False)
592+
Returned by :func:`build_debug_share` so non-CLI callers (the dashboard
593+
web server, gateway) can render the uploaded paste URLs as real links
594+
instead of scraping printed text.
595+
"""
596596

597-
if not local_only:
598-
print(_PRIVACY_NOTICE)
597+
urls: dict # label -> paste URL (e.g. {"Report": "...", "agent.log": "..."})
598+
failures: list # human-readable "label: error" strings for optional uploads
599+
redacted: bool # whether force-mode redaction was applied before upload
600+
auto_delete_seconds: int # how long until the pastes auto-delete
601+
report: str = "" # the summary report text (kept for local fallback)
599602

600-
print("Collecting debug report...")
603+
604+
def build_debug_share(
605+
*,
606+
log_lines: int = 200,
607+
expiry: int = 7,
608+
redact: bool = True,
609+
) -> DebugShareResult:
610+
"""Collect the debug report + full logs, upload each, return the URLs.
611+
612+
This is the shared core behind ``hermes debug share`` (CLI) and the
613+
dashboard ``POST /api/ops/debug-share`` endpoint. It performs blocking
614+
network I/O (paste uploads) — callers inside an event loop must run it in
615+
a worker thread.
616+
617+
The summary report upload is required: on failure this raises
618+
``RuntimeError``. Full-log uploads are best-effort; their errors are
619+
collected into ``failures`` rather than raised.
620+
"""
621+
_best_effort_sweep_expired_pastes()
601622

602623
# Capture dump once — prepended to every paste for context.
603624
# The dump is already redacted at extract time via dump.py:_redact;
@@ -639,71 +660,112 @@ def run_debug_share(args):
639660
if desktop_log:
640661
desktop_log = _REDACTION_BANNER + desktop_log
641662

663+
urls: dict[str, str] = {}
664+
failures: list[str] = []
665+
666+
# 1. Summary report (required — raises on failure so callers can fall back)
667+
urls["Report"] = upload_to_pastebin(report, expiry_days=expiry)
668+
669+
# 2-4. Full logs (optional — failures are collected, not raised)
670+
for label, content in (
671+
("agent.log", agent_log),
672+
("gateway.log", gateway_log),
673+
("desktop.log", desktop_log),
674+
):
675+
if not content:
676+
continue
677+
try:
678+
urls[label] = upload_to_pastebin(content, expiry_days=expiry)
679+
except Exception as exc:
680+
failures.append(f"{label}: {exc}")
681+
682+
# Schedule auto-deletion after 6 hours.
683+
_schedule_auto_delete(list(urls.values()))
684+
685+
return DebugShareResult(
686+
urls=urls,
687+
failures=failures,
688+
redacted=redact,
689+
auto_delete_seconds=_AUTO_DELETE_SECONDS,
690+
report=report,
691+
)
692+
693+
694+
def run_debug_share(args):
695+
"""Collect debug report + full logs, upload each, print URLs."""
696+
log_lines = getattr(args, "lines", 200)
697+
expiry = getattr(args, "expire", 7)
698+
local_only = getattr(args, "local", False)
699+
redact = not getattr(args, "no_redact", False)
700+
642701
if local_only:
643-
print(report)
702+
# Local-only path never uploads — render the report to stdout and bail
703+
# before any network I/O. Mirrors the upload path's collection logic.
704+
_best_effort_sweep_expired_pastes()
705+
print("Collecting debug report...")
706+
dump_text = _capture_dump()
707+
log_snapshots = _capture_default_log_snapshots(log_lines, redact=redact)
708+
report = collect_debug_report(
709+
log_lines=log_lines,
710+
dump_text=dump_text,
711+
log_snapshots=log_snapshots,
712+
)
713+
agent_log = log_snapshots["agent"].full_text
714+
gateway_log = log_snapshots["gateway"].full_text
715+
desktop_log = log_snapshots["desktop"].full_text
644716
if agent_log:
645-
print(f"\n\n{'=' * 60}")
646-
print("FULL agent.log")
647-
print(f"{'=' * 60}\n")
648-
print(agent_log)
717+
agent_log = dump_text + "\n\n--- full agent.log ---\n" + agent_log
649718
if gateway_log:
650-
print(f"\n\n{'=' * 60}")
651-
print("FULL gateway.log")
652-
print(f"{'=' * 60}\n")
653-
print(gateway_log)
719+
gateway_log = dump_text + "\n\n--- full gateway.log ---\n" + gateway_log
654720
if desktop_log:
655-
print(f"\n\n{'=' * 60}")
656-
print("FULL desktop.log")
657-
print(f"{'=' * 60}\n")
658-
print(desktop_log)
721+
desktop_log = dump_text + "\n\n--- full desktop.log ---\n" + desktop_log
722+
if redact:
723+
report = _REDACTION_BANNER + report
724+
if agent_log:
725+
agent_log = _REDACTION_BANNER + agent_log
726+
if gateway_log:
727+
gateway_log = _REDACTION_BANNER + gateway_log
728+
if desktop_log:
729+
desktop_log = _REDACTION_BANNER + desktop_log
730+
print(report)
731+
for title, body in (
732+
("FULL agent.log", agent_log),
733+
("FULL gateway.log", gateway_log),
734+
("FULL desktop.log", desktop_log),
735+
):
736+
if body:
737+
print(f"\n\n{'=' * 60}")
738+
print(title)
739+
print(f"{'=' * 60}\n")
740+
print(body)
659741
return
660742

743+
print(_PRIVACY_NOTICE)
744+
print("Collecting debug report...")
661745
print("Uploading...")
662-
urls: dict[str, str] = {}
663-
failures: list[str] = []
664746

665-
# 1. Summary report (required)
666747
try:
667-
urls["Report"] = upload_to_pastebin(report, expiry_days=expiry)
748+
result = build_debug_share(
749+
log_lines=log_lines,
750+
expiry=expiry,
751+
redact=redact,
752+
)
668753
except RuntimeError as exc:
669754
print(f"\nUpload failed: {exc}", file=sys.stderr)
670-
print("\nFull report printed below — copy-paste it manually:\n")
671-
print(report)
755+
print("\nRun `hermes debug share --local` to print the report instead.\n")
672756
sys.exit(1)
673757

674-
# 2. Full agent.log (optional)
675-
if agent_log:
676-
try:
677-
urls["agent.log"] = upload_to_pastebin(agent_log, expiry_days=expiry)
678-
except Exception as exc:
679-
failures.append(f"agent.log: {exc}")
680-
681-
# 3. Full gateway.log (optional)
682-
if gateway_log:
683-
try:
684-
urls["gateway.log"] = upload_to_pastebin(gateway_log, expiry_days=expiry)
685-
except Exception as exc:
686-
failures.append(f"gateway.log: {exc}")
687-
688-
# 4. Full desktop.log (optional — Electron app boot + backend output)
689-
if desktop_log:
690-
try:
691-
urls["desktop.log"] = upload_to_pastebin(desktop_log, expiry_days=expiry)
692-
except Exception as exc:
693-
failures.append(f"desktop.log: {exc}")
694-
695758
# Print results
696-
label_width = max(len(k) for k in urls)
759+
label_width = max(len(k) for k in result.urls)
697760
print(f"\nDebug report uploaded:")
698-
for label, url in urls.items():
761+
for label, url in result.urls.items():
699762
print(f" {label:<{label_width}} {url}")
700763

701-
if failures:
702-
print(f"\n (failed to upload: {', '.join(failures)})")
764+
if result.failures:
765+
print(f"\n (failed to upload: {', '.join(result.failures)})")
703766

704-
# Schedule auto-deletion after 6 hours
705-
_schedule_auto_delete(list(urls.values()))
706-
print(f"\n⏱ Pastes will auto-delete in 6 hours.")
767+
hours = result.auto_delete_seconds // 3600
768+
print(f"\n⏱ Pastes will auto-delete in {hours} hours.")
707769

708770
# Manual delete fallback
709771
print(f"To delete now: hermes debug delete <url>")

‎hermes_cli/web_server.py‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1016,6 +1016,51 @@ async def run_config_migrate():
10161016
return {"ok": True, "pid": proc.pid, "name": "config-migrate"}
10171017

10181018

1019+
class DebugShareRequest(BaseModel):
1020+
# Redaction is ON by default — force-mode scrubs credential-shaped tokens
1021+
# out of log content before it leaves the machine. The toggle exists so an
1022+
# operator who knows the logs are clean can opt out for fuller fidelity.
1023+
redact: bool = True
1024+
# Recent log lines included in the summary tail (full logs are separate).
1025+
lines: int = 200
1026+
1027+
1028+
@app.post("/api/ops/debug-share")
1029+
async def run_debug_share_endpoint(body: DebugShareRequest | None = None):
1030+
"""Upload a redacted debug report + full logs and return the paste URLs.
1031+
1032+
Unlike the other diagnostics actions (doctor, dump, prompt-size) this is
1033+
*synchronous*: the whole point of ``debug share`` is the set of shareable
1034+
URLs it produces, so we run the upload in a worker thread and return the
1035+
structured ``{urls, failures, redacted, ...}`` payload directly. The
1036+
dashboard renders those as real, copyable links instead of scraping a log
1037+
tail. Pastes auto-delete after 6 hours (handled inside the share core).
1038+
"""
1039+
from hermes_cli.debug import build_debug_share
1040+
1041+
req = body or DebugShareRequest()
1042+
try:
1043+
result = await asyncio.to_thread(
1044+
build_debug_share,
1045+
log_lines=max(1, min(int(req.lines), 5000)),
1046+
redact=bool(req.redact),
1047+
)
1048+
except RuntimeError as exc:
1049+
# Required summary-report upload failed (offline / paste service down).
1050+
raise HTTPException(status_code=502, detail=f"Upload failed: {exc}")
1051+
except Exception as exc:
1052+
_log.exception("debug share failed")
1053+
raise HTTPException(status_code=500, detail=f"Failed: {exc}")
1054+
1055+
return {
1056+
"ok": True,
1057+
"urls": result.urls,
1058+
"failures": result.failures,
1059+
"redacted": result.redacted,
1060+
"auto_delete_seconds": result.auto_delete_seconds,
1061+
}
1062+
1063+
10191064
# ---------------------------------------------------------------------------
10201065
# Gateway + update actions (invoked from the Status page).
10211066
#

0 commit comments

Comments
 (0)