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:
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user