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