diff --git a/gateway/run.py b/gateway/run.py index ea45dcdd3d..4ccc7131aa 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -6803,11 +6803,17 @@ class GatewayRunner: }) async def _handle_debug_command(self, event: MessageEvent) -> str: - """Handle /debug — upload debug report + logs and return paste URLs.""" + """Handle /debug — upload debug report (summary only) and return paste URLs. + + Gateway uploads ONLY the summary report (system info + log tails), + NOT full log files, to protect conversation privacy. Users who need + full log uploads should use ``hermes debug share`` from the CLI. + """ import asyncio from hermes_cli.debug import ( - _capture_dump, collect_debug_report, _read_full_log, - upload_to_pastebin, + _capture_dump, collect_debug_report, + upload_to_pastebin, _schedule_auto_delete, + _GATEWAY_PRIVACY_NOTICE, ) loop = asyncio.get_running_loop() @@ -6816,43 +6822,25 @@ class GatewayRunner: def _collect_and_upload(): dump_text = _capture_dump() report = collect_debug_report(log_lines=200, dump_text=dump_text) - agent_log = _read_full_log("agent") - gateway_log = _read_full_log("gateway") - - if agent_log: - agent_log = dump_text + "\n\n--- full agent.log ---\n" + agent_log - if gateway_log: - gateway_log = dump_text + "\n\n--- full gateway.log ---\n" + gateway_log urls = {} - failures = [] - try: urls["Report"] = upload_to_pastebin(report) except Exception as exc: return f"✗ Failed to upload debug report: {exc}" - if agent_log: - try: - urls["agent.log"] = upload_to_pastebin(agent_log) - except Exception: - failures.append("agent.log") + # Schedule auto-deletion after 1 hour + _schedule_auto_delete(list(urls.values())) - if gateway_log: - try: - urls["gateway.log"] = upload_to_pastebin(gateway_log) - except Exception: - failures.append("gateway.log") - - lines = ["**Debug report uploaded:**", ""] + lines = [_GATEWAY_PRIVACY_NOTICE, "", "**Debug report uploaded:**", ""] label_width = max(len(k) for k in urls) for label, url in urls.items(): lines.append(f"`{label:<{label_width}}` {url}") - if failures: - lines.append(f"\n_(failed to upload: {', '.join(failures)})_") - - lines.append("\nShare these links with the Hermes team for support.") + lines.append("") + lines.append("⏱ Pastes will auto-delete in 1 hour.") + lines.append("For full log uploads, use `hermes debug share` from the CLI.") + lines.append("Share these links with the Hermes team for support.") return "\n".join(lines) return await loop.run_in_executor(None, _collect_and_upload) diff --git a/hermes_cli/debug.py b/hermes_cli/debug.py index 3607db9231..12cdb1ba60 100644 --- a/hermes_cli/debug.py +++ b/hermes_cli/debug.py @@ -27,6 +27,110 @@ _DPASTE_COM_URL = "https://dpaste.com/api/" # paste.rs caps at ~1 MB; we stay under that with headroom. _MAX_LOG_BYTES = 512_000 +# Auto-delete pastes after this many seconds (1 hour). +_AUTO_DELETE_SECONDS = 3600 + + +# --------------------------------------------------------------------------- +# Privacy / delete helpers +# --------------------------------------------------------------------------- + +_PRIVACY_NOTICE = """\ +⚠️ 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 — may contain + conversation fragments and file paths) + • Full agent.log and gateway.log (up to 512 KB each — likely contains + conversation content, tool outputs, and file paths) + +Pastes auto-delete after 1 hour. +""" + +_GATEWAY_PRIVACY_NOTICE = ( + "⚠️ **Privacy notice:** This uploads system info + recent log tails " + "(may contain conversation fragments) to a public paste service. " + "Full logs are NOT included from the gateway — use `hermes debug share` " + "from the CLI for full log uploads.\n" + "Pastes auto-delete after 1 hour." +) + + +def _extract_paste_id(url: str) -> Optional[str]: + """Extract the paste ID from a paste.rs or dpaste.com URL. + + Returns the ID string, or None if the URL doesn't match a known service. + """ + url = url.strip().rstrip("/") + for prefix in ("https://paste.rs/", "http://paste.rs/"): + if url.startswith(prefix): + return url[len(prefix):] + return None + + +def delete_paste(url: str) -> bool: + """Delete a paste from paste.rs. Returns True on success. + + Only paste.rs supports unauthenticated DELETE. dpaste.com pastes + expire automatically but cannot be deleted via API. + """ + paste_id = _extract_paste_id(url) + if not paste_id: + raise ValueError( + f"Cannot delete: only paste.rs URLs are supported. Got: {url}" + ) + + target = f"{_PASTE_RS_URL}{paste_id}" + req = urllib.request.Request( + target, method="DELETE", + headers={"User-Agent": "hermes-agent/debug-share"}, + ) + with urllib.request.urlopen(req, timeout=30) as resp: + return 200 <= resp.status < 300 + + +def _schedule_auto_delete(urls: list[str], delay_seconds: int = _AUTO_DELETE_SECONDS): + """Spawn a detached process to delete paste.rs pastes after *delay_seconds*. + + The child process is fully detached (``start_new_session=True``) so it + survives the parent exiting (important for CLI mode). Only paste.rs + URLs are attempted — dpaste.com pastes auto-expire on their own. + """ + import subprocess + + paste_rs_urls = [u for u in urls if _extract_paste_id(u)] + if not paste_rs_urls: + return + + # Build a tiny inline Python script. No imports beyond stdlib. + url_list = ", ".join(f'"{u}"' for u in paste_rs_urls) + script = ( + "import time, urllib.request; " + f"time.sleep({delay_seconds}); " + f"[urllib.request.urlopen(urllib.request.Request(u, method='DELETE', " + f"headers={{'User-Agent': 'hermes-agent/auto-delete'}}), timeout=15) " + f"for u in [{url_list}]]" + ) + + try: + subprocess.Popen( + [sys.executable, "-c", script], + start_new_session=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + except Exception: + pass # Best-effort; manual delete still available. + + +def _delete_hint(url: str) -> str: + """Return a one-liner delete command for the given paste URL.""" + paste_id = _extract_paste_id(url) + if paste_id: + return f"hermes debug delete {url}" + # dpaste.com — no API delete, expires on its own. + return "(auto-expires per dpaste.com policy)" + def _upload_paste_rs(content: str) -> str: """Upload to paste.rs. Returns the paste URL. @@ -250,6 +354,9 @@ def run_debug_share(args): expiry = getattr(args, "expire", 7) local_only = getattr(args, "local", False) + if not local_only: + print(_PRIVACY_NOTICE) + print("Collecting debug report...") # Capture dump once — prepended to every paste for context. @@ -315,22 +422,56 @@ def run_debug_share(args): if failures: print(f"\n (failed to upload: {', '.join(failures)})") + # Schedule auto-deletion after 1 hour + _schedule_auto_delete(list(urls.values())) + print(f"\n⏱ Pastes will auto-delete in 1 hour.") + + # Manual delete fallback + print(f"To delete now: hermes debug delete ") + print(f"\nShare these links with the Hermes team for support.") +def run_debug_delete(args): + """Delete one or more paste URLs uploaded by /debug.""" + urls = getattr(args, "urls", []) + if not urls: + print("Usage: hermes debug delete [ ...]") + print(" Deletes paste.rs pastes uploaded by 'hermes debug share'.") + return + + for url in urls: + try: + ok = delete_paste(url) + if ok: + print(f" ✓ Deleted: {url}") + else: + print(f" ✗ Failed to delete: {url} (unexpected response)") + except ValueError as exc: + print(f" ✗ {exc}") + except Exception as exc: + print(f" ✗ Could not delete {url}: {exc}") + + def run_debug(args): """Route debug subcommands.""" subcmd = getattr(args, "debug_command", None) if subcmd == "share": run_debug_share(args) + elif subcmd == "delete": + run_debug_delete(args) else: # Default: show help - print("Usage: hermes debug share [--lines N] [--expire N] [--local]") + print("Usage: hermes debug ") print() print("Commands:") print(" share Upload debug report to a paste service and print URL") + print(" delete Delete a previously uploaded paste") print() - print("Options:") + print("Options (share):") print(" --lines N Number of log lines to include (default: 200)") print(" --expire N Paste expiry in days (default: 7)") print(" --local Print report locally instead of uploading") + print() + print("Options (delete):") + print(" ... One or more paste URLs to delete") diff --git a/hermes_cli/main.py b/hermes_cli/main.py index b45c9abb8d..2eb47aa54b 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -5073,6 +5073,7 @@ Examples: hermes debug share --lines 500 Include more log lines hermes debug share --expire 30 Keep paste for 30 days hermes debug share --local Print report locally (no upload) + hermes debug delete Delete a previously uploaded paste """, ) debug_sub = debug_parser.add_subparsers(dest="debug_command") @@ -5092,6 +5093,14 @@ Examples: "--local", action="store_true", help="Print the report locally instead of uploading", ) + delete_parser = debug_sub.add_parser( + "delete", + help="Delete a paste uploaded by 'hermes debug share'", + ) + delete_parser.add_argument( + "urls", nargs="*", default=[], + help="One or more paste URLs to delete (e.g. https://paste.rs/abc123)", + ) debug_parser.set_defaults(func=cmd_debug) # ========================================================================= diff --git a/tests/hermes_cli/test_debug.py b/tests/hermes_cli/test_debug.py index f733c8ab64..864a64160f 100644 --- a/tests/hermes_cli/test_debug.py +++ b/tests/hermes_cli/test_debug.py @@ -428,7 +428,9 @@ class TestRunDebug: run_debug(args) out = capsys.readouterr().out - assert "hermes debug share" in out + assert "hermes debug" in out + assert "share" in out + assert "delete" in out def test_share_subcommand_routes(self, hermes_home): from hermes_cli.debug import run_debug @@ -459,3 +461,187 @@ class TestArgparseIntegration: args = MagicMock() args.debug_command = None cmd_debug(args) + + +# --------------------------------------------------------------------------- +# Delete / auto-delete +# --------------------------------------------------------------------------- + +class TestExtractPasteId: + def test_paste_rs_url(self): + from hermes_cli.debug import _extract_paste_id + assert _extract_paste_id("https://paste.rs/abc123") == "abc123" + + def test_paste_rs_trailing_slash(self): + from hermes_cli.debug import _extract_paste_id + assert _extract_paste_id("https://paste.rs/abc123/") == "abc123" + + def test_http_variant(self): + from hermes_cli.debug import _extract_paste_id + assert _extract_paste_id("http://paste.rs/xyz") == "xyz" + + def test_non_paste_rs_returns_none(self): + from hermes_cli.debug import _extract_paste_id + assert _extract_paste_id("https://dpaste.com/ABCDEF") is None + + def test_empty_returns_none(self): + from hermes_cli.debug import _extract_paste_id + assert _extract_paste_id("") is None + + +class TestDeletePaste: + def test_delete_sends_delete_request(self): + from hermes_cli.debug import delete_paste + + mock_resp = MagicMock() + mock_resp.status = 200 + mock_resp.__enter__ = lambda s: s + mock_resp.__exit__ = MagicMock(return_value=False) + + with patch("hermes_cli.debug.urllib.request.urlopen", + return_value=mock_resp) as mock_open: + result = delete_paste("https://paste.rs/abc123") + + assert result is True + req = mock_open.call_args[0][0] + assert req.method == "DELETE" + assert "paste.rs/abc123" in req.full_url + + def test_delete_rejects_non_paste_rs(self): + from hermes_cli.debug import delete_paste + + with pytest.raises(ValueError, match="only paste.rs"): + delete_paste("https://dpaste.com/something") + + +class TestScheduleAutoDelete: + def test_spawns_detached_process(self): + from hermes_cli.debug import _schedule_auto_delete + + with patch("subprocess.Popen") as mock_popen: + _schedule_auto_delete( + ["https://paste.rs/abc", "https://paste.rs/def"], + delay_seconds=10, + ) + + mock_popen.assert_called_once() + call_args = mock_popen.call_args + # Verify detached + assert call_args[1]["start_new_session"] is True + # Verify the script references both URLs + script = call_args[0][0][2] # [python, -c, script] + assert "paste.rs/abc" in script + assert "paste.rs/def" in script + assert "time.sleep(10)" in script + + def test_skips_non_paste_rs_urls(self): + from hermes_cli.debug import _schedule_auto_delete + + with patch("subprocess.Popen") as mock_popen: + _schedule_auto_delete(["https://dpaste.com/something"]) + + mock_popen.assert_not_called() + + def test_handles_popen_failure_gracefully(self): + from hermes_cli.debug import _schedule_auto_delete + + with patch("subprocess.Popen", + side_effect=OSError("no such file")): + # Should not raise + _schedule_auto_delete(["https://paste.rs/abc"]) + + +class TestRunDebugDelete: + def test_deletes_valid_url(self, capsys): + from hermes_cli.debug import run_debug_delete + + args = MagicMock() + args.urls = ["https://paste.rs/abc"] + + with patch("hermes_cli.debug.delete_paste", return_value=True): + run_debug_delete(args) + + out = capsys.readouterr().out + assert "Deleted" in out + assert "paste.rs/abc" in out + + def test_handles_delete_failure(self, capsys): + from hermes_cli.debug import run_debug_delete + + args = MagicMock() + args.urls = ["https://paste.rs/abc"] + + with patch("hermes_cli.debug.delete_paste", + side_effect=Exception("network error")): + run_debug_delete(args) + + out = capsys.readouterr().out + assert "Could not delete" in out + + def test_no_urls_shows_usage(self, capsys): + from hermes_cli.debug import run_debug_delete + + args = MagicMock() + args.urls = [] + + run_debug_delete(args) + + out = capsys.readouterr().out + assert "Usage" in out + + +class TestShareIncludesAutoDelete: + """Verify that run_debug_share schedules auto-deletion and prints TTL.""" + + def test_share_schedules_auto_delete(self, hermes_home, capsys): + from hermes_cli.debug import run_debug_share + + args = MagicMock() + args.lines = 50 + args.expire = 7 + args.local = False + + with patch("hermes_cli.dump.run_dump"), \ + patch("hermes_cli.debug.upload_to_pastebin", + return_value="https://paste.rs/test1"), \ + patch("hermes_cli.debug._schedule_auto_delete") as mock_sched: + run_debug_share(args) + + # auto-delete was scheduled with the uploaded URLs + mock_sched.assert_called_once() + urls_arg = mock_sched.call_args[0][0] + assert "https://paste.rs/test1" in urls_arg + + out = capsys.readouterr().out + assert "auto-delete" in out + + def test_share_shows_privacy_notice(self, hermes_home, capsys): + from hermes_cli.debug import run_debug_share + + args = MagicMock() + args.lines = 50 + args.expire = 7 + args.local = False + + with patch("hermes_cli.dump.run_dump"), \ + patch("hermes_cli.debug.upload_to_pastebin", + return_value="https://paste.rs/test"), \ + patch("hermes_cli.debug._schedule_auto_delete"): + run_debug_share(args) + + out = capsys.readouterr().out + assert "public paste service" in out + + def test_local_no_privacy_notice(self, hermes_home, capsys): + from hermes_cli.debug import run_debug_share + + args = MagicMock() + args.lines = 50 + args.expire = 7 + args.local = True + + with patch("hermes_cli.dump.run_dump"): + run_debug_share(args) + + out = capsys.readouterr().out + assert "public paste service" not in out