feat(dashboard): add Debug Share to the System page (#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).
This commit is contained in:
Teknium
2026-06-03 19:37:04 -07:00
committed by GitHub
parent f66a929a6b
commit e3313c50a7
8 changed files with 696 additions and 65 deletions
+64
View File
@@ -157,6 +157,70 @@ class TestLoadConfigParseFailure:
after_edit = capsys.readouterr().err
assert "hermes config:" in after_edit, "edited file should re-warn"
def test_corrupt_config_is_backed_up(self, tmp_path, capsys):
"""A broken config.yaml is snapshotted to a timestamped .bak so the
user's recoverable overrides survive a later wizard/config-set rewrite.
Ported from google-gemini/gemini-cli#21541 (policy-file TOML recovery),
adapted: we back up but deliberately do NOT reset config.yaml.
"""
from hermes_cli import config as cfg_mod
cfg_mod._CONFIG_PARSE_WARNED.clear()
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
broken = "\tmodel: test/custom\nbroken indent:\n"
(tmp_path / "config.yaml").write_text(broken)
load_config()
err = capsys.readouterr().err
baks = list(tmp_path.glob("config.yaml.corrupt.*.bak"))
assert len(baks) == 1, f"expected one backup, got {baks}"
# Backup preserves the original broken content verbatim
assert baks[0].read_text() == broken
# Original config.yaml is left untouched (not reset to clean state)
assert (tmp_path / "config.yaml").read_text() == broken
# User is told where the backup landed
assert str(baks[0]) in err
def test_backup_skips_when_same_size_bak_exists(self, tmp_path, capsys):
"""Don't churn backups: if a corrupt backup of the same size already
exists (same corruption already preserved), skip making another."""
from hermes_cli import config as cfg_mod
cfg_mod._CONFIG_PARSE_WARNED.clear()
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
broken = "\tbroken:\n"
cfg = tmp_path / "config.yaml"
cfg.write_text(broken)
# Pre-existing backup of identical size simulates an earlier snapshot.
(tmp_path / "config.yaml.corrupt.20260101-000000.bak").write_text(broken)
load_config()
baks = list(tmp_path.glob("config.yaml.corrupt.*.bak"))
assert len(baks) == 1, f"should not add a second same-size backup, got {baks}"
def test_corrupt_symlink_config_not_backed_up(self, tmp_path):
"""Symlinked config.yaml is not copied (mirrors Gemini #21541 lstat
guard) — avoids clobbering whatever the symlink points at."""
import sys as _sys
if _sys.platform == "win32":
pytest.skip("symlink creation requires privileges on Windows")
from hermes_cli import config as cfg_mod
cfg_mod._CONFIG_PARSE_WARNED.clear()
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
real = tmp_path / "real_config.yaml"
real.write_text("\tbroken:\n")
link = tmp_path / "config.yaml"
link.symlink_to(real)
load_config()
assert not list(tmp_path.glob("config.yaml.corrupt.*.bak"))
class TestSaveAndLoadRoundtrip:
def test_roundtrip(self, tmp_path):
@@ -498,3 +498,96 @@ class TestUpdateCheckEndpoint:
assert body["update_available"] is False
assert body["message"]
class TestDebugShareEndpoint:
"""POST /api/ops/debug-share returns the paste URLs synchronously so the
dashboard can render them as copyable links (not a backgrounded log tail)."""
@pytest.fixture(autouse=True)
def _setup(self, _isolate_hermes_home):
self.client, self.header = _client()
from hermes_constants import get_hermes_home
logs = get_hermes_home() / "logs"
logs.mkdir(parents=True, exist_ok=True)
(logs / "agent.log").write_text("agent line\n")
(logs / "errors.log").write_text("err line\n")
(logs / "gateway.log").write_text("gw line\n")
def test_returns_structured_urls(self, monkeypatch):
import hermes_cli.debug as dbg
count = [0]
def _upload(content, expiry_days=7):
count[0] += 1
return f"https://paste.rs/p{count[0]}"
monkeypatch.setattr(dbg, "upload_to_pastebin", _upload)
monkeypatch.setattr(dbg, "_schedule_auto_delete", lambda *a, **k: None)
monkeypatch.setattr(dbg, "_best_effort_sweep_expired_pastes", lambda: None)
monkeypatch.setattr("hermes_cli.dump.run_dump", lambda a: None)
r = self.client.post("/api/ops/debug-share", json={"redact": True})
assert r.status_code == 200
body = r.json()
assert body["ok"] is True
assert "Report" in body["urls"]
assert body["redacted"] is True
assert body["auto_delete_seconds"] == 21600
assert isinstance(body["failures"], list)
def test_redact_false_is_honored(self, monkeypatch):
import hermes_cli.debug as dbg
monkeypatch.setattr(
dbg, "upload_to_pastebin", lambda c, expiry_days=7: "https://paste.rs/x"
)
monkeypatch.setattr(dbg, "_schedule_auto_delete", lambda *a, **k: None)
monkeypatch.setattr(dbg, "_best_effort_sweep_expired_pastes", lambda: None)
monkeypatch.setattr("hermes_cli.dump.run_dump", lambda a: None)
r = self.client.post("/api/ops/debug-share", json={"redact": False})
assert r.status_code == 200
assert r.json()["redacted"] is False
def test_default_body_redacts(self, monkeypatch):
import hermes_cli.debug as dbg
monkeypatch.setattr(
dbg, "upload_to_pastebin", lambda c, expiry_days=7: "https://paste.rs/x"
)
monkeypatch.setattr(dbg, "_schedule_auto_delete", lambda *a, **k: None)
monkeypatch.setattr(dbg, "_best_effort_sweep_expired_pastes", lambda: None)
monkeypatch.setattr("hermes_cli.dump.run_dump", lambda a: None)
# No JSON body at all — should default redact=True.
r = self.client.post("/api/ops/debug-share")
assert r.status_code == 200
assert r.json()["redacted"] is True
def test_upload_failure_returns_502(self, monkeypatch):
import hermes_cli.debug as dbg
monkeypatch.setattr(
dbg,
"upload_to_pastebin",
lambda c, expiry_days=7: (_ for _ in ()).throw(RuntimeError("down")),
)
monkeypatch.setattr(dbg, "_schedule_auto_delete", lambda *a, **k: None)
monkeypatch.setattr(dbg, "_best_effort_sweep_expired_pastes", lambda: None)
monkeypatch.setattr("hermes_cli.dump.run_dump", lambda a: None)
r = self.client.post("/api/ops/debug-share", json={"redact": True})
assert r.status_code == 502
def test_requires_session_token(self):
# Drop the token header and confirm the global auth gate rejects it.
bare = self.client
r = bare.post(
"/api/ops/debug-share",
json={"redact": True},
headers={self.header: "wrong-token"},
)
assert r.status_code == 401
+107
View File
@@ -1273,3 +1273,110 @@ class TestShareIncludesAutoDelete:
out = capsys.readouterr().out
assert "public paste service" not in out
# ---------------------------------------------------------------------------
# build_debug_share — structured core used by the dashboard endpoint
# ---------------------------------------------------------------------------
class TestBuildDebugShare:
"""The shared core that returns structured paste URLs (not printed text).
Backs both ``hermes debug share`` (CLI) and ``POST /api/ops/debug-share``
(dashboard). The dashboard renders ``urls`` as real, copyable links, so the
contract here is the return value, not stdout.
"""
def test_returns_structured_urls(self, hermes_home):
from hermes_cli.debug import build_debug_share, DebugShareResult
count = [0]
def _upload(content, expiry_days=7):
count[0] += 1
return f"https://paste.rs/p{count[0]}"
with patch("hermes_cli.dump.run_dump"), patch(
"hermes_cli.debug.upload_to_pastebin", side_effect=_upload
), patch("hermes_cli.debug._schedule_auto_delete"):
result = build_debug_share(log_lines=50, redact=True)
assert isinstance(result, DebugShareResult)
# All four seeded logs (agent/gateway/desktop) + the summary report.
assert "Report" in result.urls
assert "agent.log" in result.urls
assert "gateway.log" in result.urls
assert "desktop.log" in result.urls
assert result.failures == []
assert result.redacted is True
assert result.auto_delete_seconds == 21600
def test_skips_missing_logs_without_failure(self, hermes_home):
from hermes_cli.debug import build_debug_share
# Remove desktop.log so it should be neither uploaded nor reported failed.
(hermes_home / "logs" / "desktop.log").unlink()
with patch("hermes_cli.dump.run_dump"), patch(
"hermes_cli.debug.upload_to_pastebin",
side_effect=lambda c, expiry_days=7: "https://paste.rs/x",
), patch("hermes_cli.debug._schedule_auto_delete"):
result = build_debug_share(log_lines=50, redact=True)
assert "desktop.log" not in result.urls
assert result.failures == []
def test_redaction_keeps_secrets_out_of_payload(self, hermes_home):
from hermes_cli.debug import build_debug_share
secret = "sk-proj-SUPERSECRETtoken1234567890"
(hermes_home / "logs" / "agent.log").write_text(
f"line one\nauthorization token={secret}\nline three\n"
)
uploaded = []
def _upload(content, expiry_days=7):
uploaded.append(content)
return "https://paste.rs/x"
with patch("hermes_cli.dump.run_dump"), patch(
"hermes_cli.debug.upload_to_pastebin", side_effect=_upload
), patch("hermes_cli.debug._schedule_auto_delete"):
result = build_debug_share(log_lines=50, redact=True)
assert result.redacted is True
joined = "\n".join(uploaded)
assert secret not in joined, "secret leaked into upload payload"
def test_optional_log_failure_is_collected_not_raised(self, hermes_home):
from hermes_cli.debug import build_debug_share
count = [0]
def _upload(content, expiry_days=7):
count[0] += 1
# First call (the required Report) succeeds; a later one fails.
if count[0] == 2:
raise RuntimeError("paste service hiccup")
return f"https://paste.rs/p{count[0]}"
with patch("hermes_cli.dump.run_dump"), patch(
"hermes_cli.debug.upload_to_pastebin", side_effect=_upload
), patch("hermes_cli.debug._schedule_auto_delete"):
result = build_debug_share(log_lines=50, redact=True)
assert "Report" in result.urls
assert len(result.failures) == 1
assert "paste service hiccup" in result.failures[0]
def test_required_report_failure_raises(self, hermes_home):
from hermes_cli.debug import build_debug_share
with patch("hermes_cli.dump.run_dump"), patch(
"hermes_cli.debug.upload_to_pastebin",
side_effect=RuntimeError("all paste services down"),
), patch("hermes_cli.debug._schedule_auto_delete"):
with pytest.raises(RuntimeError, match="all paste services down"):
build_debug_share(log_lines=50, redact=True)