"""Tests for ``hermes debug`` CLI command and debug utilities.""" import os import urllib.error from unittest.mock import MagicMock, patch import pytest # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @pytest.fixture def hermes_home(tmp_path, monkeypatch): """Set up an isolated HERMES_HOME with minimal logs.""" home = tmp_path / ".hermes" home.mkdir() monkeypatch.setenv("HERMES_HOME", str(home)) # Create log files logs_dir = home / "logs" logs_dir.mkdir() (logs_dir / "agent.log").write_text( "2026-04-12 17:00:00 INFO agent: session started\n" "2026-04-12 17:00:01 INFO tools.terminal: running ls\n" "2026-04-12 17:00:02 WARNING agent: high token usage\n" ) (logs_dir / "errors.log").write_text( "2026-04-12 17:00:05 ERROR gateway.run: connection lost\n" ) (logs_dir / "gateway.log").write_text( "2026-04-12 17:00:10 INFO gateway.run: started\n" ) (logs_dir / "gui.log").write_text( "2026-04-12 17:00:12 INFO hermes_cli.web_server: dashboard request\n" ) (logs_dir / "desktop.log").write_text( "2026-04-12 17:00:15 INFO desktop: backend spawned\n" ) return home # --------------------------------------------------------------------------- # Unit tests for upload helpers # --------------------------------------------------------------------------- class TestUploadPasteRs: """Test paste.rs upload path.""" def test_upload_paste_rs_success(self): from hermes_cli.debug import _upload_paste_rs mock_resp = MagicMock() mock_resp.read.return_value = b"https://paste.rs/abc123\n" 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): url = _upload_paste_rs("hello world") assert url == "https://paste.rs/abc123" def test_upload_paste_rs_network_error(self): from hermes_cli.debug import _upload_paste_rs with patch( "hermes_cli.debug.urllib.request.urlopen", side_effect=urllib.error.URLError("connection refused"), ): with pytest.raises(urllib.error.URLError): _upload_paste_rs("test") class TestUploadToPastebin: """Test the combined upload with fallback.""" def test_falls_back_to_dpaste_com(self): from hermes_cli.debug import upload_to_pastebin with patch("hermes_cli.debug._upload_paste_rs", side_effect=Exception("down")), \ patch("hermes_cli.debug._upload_dpaste_com", return_value="https://dpaste.com/TEST") as dp: url = upload_to_pastebin("content") assert url == "https://dpaste.com/TEST" dp.assert_called_once() def test_raises_when_both_fail(self): from hermes_cli.debug import upload_to_pastebin with patch("hermes_cli.debug._upload_paste_rs", side_effect=Exception("err1")), \ patch("hermes_cli.debug._upload_dpaste_com", side_effect=Exception("err2")): with pytest.raises(RuntimeError, match="Failed to upload"): upload_to_pastebin("content") # --------------------------------------------------------------------------- # Log reading # --------------------------------------------------------------------------- class TestCaptureLogSnapshot: """Test _capture_log_snapshot for log reading and truncation.""" def test_race_truncate_after_resolve_reports_empty(self, hermes_home, monkeypatch): """If the log is truncated between resolve and stat, say 'empty', not 'missing'.""" log_path = hermes_home / "logs" / "agent.log" from hermes_cli import debug monkeypatch.setattr(debug, "_resolve_log_path", lambda _name: log_path) log_path.write_text("") snap = debug._capture_log_snapshot("agent", tail_lines=10) assert snap.path == log_path assert snap.full_text is None assert snap.tail_text == "(file empty)" def test_keeps_first_line_when_truncation_on_boundary(self, hermes_home): """When truncation lands on a line boundary, keep the first full line.""" from hermes_cli.debug import _capture_log_snapshot # File must exceed the initial chunk_size (8192) used by the # backward-reading loop so the truncation path actually fires. line = "A" * 99 + "\n" # 100 bytes per line num_lines = 200 # 20000 bytes (hermes_home / "logs" / "agent.log").write_text(line * num_lines) # max_bytes = 1000 = 100 * 10 → cut at byte 20000 - 1000 = 19000, # and byte 19000 - 1 is '\n'. Boundary hit → keep all 10 lines. snap = _capture_log_snapshot("agent", tail_lines=5, max_bytes=1000) assert snap.full_text is not None assert "truncated" in snap.full_text raw = snap.full_text.split("\n", 1)[1] kept = [l for l in raw.strip().splitlines() if l.startswith("A")] assert len(kept) == 10 # --------------------------------------------------------------------------- # Capture log redaction (force=True applies regardless of HERMES_REDACT_SECRETS) # --------------------------------------------------------------------------- # A vendor-prefixed token used across redaction tests. Long enough to clear # the redactor's `floor` parameter so it actually masks rather than fully blanks. _REDACT_FIXTURE_TOKEN = "sk-proj-A1B2C3D4E5F6G7H8I9J0aA" class TestCaptureLogSnapshotRedaction: """Pin upload-time redaction at the _capture_log_snapshot boundary.""" @pytest.fixture def hermes_home_with_secret(self, tmp_path, monkeypatch): """Isolated HERMES_HOME whose agent.log contains a vendor-prefixed token.""" home = tmp_path / ".hermes" home.mkdir() monkeypatch.setenv("HERMES_HOME", str(home)) # Baseline fixture: no explicit env-var opinion. With the post-#17691 # default of ON, the default-path tests below exercise the # secure-default behaviour. The `force=True` regression test # setenvs to "false" inline to prove force=True works even when # the runtime flag is disabled. monkeypatch.delenv("HERMES_REDACT_SECRETS", raising=False) logs_dir = home / "logs" logs_dir.mkdir() (logs_dir / "agent.log").write_text( f"2026-04-12 17:00:00 INFO config: api_key={_REDACT_FIXTURE_TOKEN} loaded\n" ) (logs_dir / "errors.log").write_text("") (logs_dir / "gateway.log").write_text("") return home def test_default_redacts_tail_and_full_text(self, hermes_home_with_secret): from hermes_cli.debug import _capture_log_snapshot snap = _capture_log_snapshot("agent", tail_lines=10) # Both views the upload uses must be sanitized. assert _REDACT_FIXTURE_TOKEN not in snap.tail_text assert snap.full_text is not None assert _REDACT_FIXTURE_TOKEN not in snap.full_text def test_redact_false_passes_through(self, hermes_home_with_secret): from hermes_cli.debug import _capture_log_snapshot snap = _capture_log_snapshot("agent", tail_lines=10, redact=False) # Original token survives when the caller opts out. assert _REDACT_FIXTURE_TOKEN in snap.tail_text assert _REDACT_FIXTURE_TOKEN in (snap.full_text or "") def test_force_true_works_when_redaction_disabled( self, hermes_home_with_secret, monkeypatch ): """Regression test: redact_sensitive_text short-circuits without force=True. If a future refactor drops `force=True` from `_redact_log_text`, this test fails immediately. Without `force=True`, the redactor returns the input unchanged when HERMES_REDACT_SECRETS=false, and the share-time redaction feature ships silently broken for users who opted out of runtime redaction (e.g. developers working on the redactor itself). """ # Force the runtime flag off so we're exercising the force=True path, # not the default-on path. monkeypatch.setenv("HERMES_REDACT_SECRETS", "false") from hermes_cli.debug import _capture_log_snapshot assert os.environ.get("HERMES_REDACT_SECRETS", "") == "false" snap = _capture_log_snapshot("agent", tail_lines=10) assert _REDACT_FIXTURE_TOKEN not in snap.tail_text assert snap.full_text is not None assert _REDACT_FIXTURE_TOKEN not in snap.full_text def test_default_redacts_email_addresses_for_public_share( self, hermes_home_with_secret ): from hermes_cli.debug import _capture_log_snapshot log_path = hermes_home_with_secret / "logs" / "agent.log" log_path.write_text( "2026-04-12 17:00:00 INFO gateway.run: " "inbound message: platform=bluebubbles " "user=person@example.com chat=iMessage;-;person@example.com msg='hello'\n" ) snap = _capture_log_snapshot("agent", tail_lines=10) assert "person@example.com" not in snap.tail_text assert "[REDACTED_EMAIL]" in snap.tail_text assert snap.full_text is not None assert "person@example.com" not in snap.full_text def test_no_redact_preserves_email_addresses(self, hermes_home_with_secret): from hermes_cli.debug import _capture_log_snapshot log_path = hermes_home_with_secret / "logs" / "agent.log" log_path.write_text( "2026-04-12 17:00:00 INFO gateway.run: " "inbound message: platform=bluebubbles " "user=person@example.com chat=iMessage;-;person@example.com msg='hello'\n" ) snap = _capture_log_snapshot("agent", tail_lines=10, redact=False) assert "person@example.com" in snap.tail_text assert "person@example.com" in (snap.full_text or "") def test_capture_default_log_snapshots_threads_redact( self, hermes_home_with_secret ): from hermes_cli.debug import _capture_default_log_snapshots snaps = _capture_default_log_snapshots(50) # Default threads redact=True to all three captured logs. assert _REDACT_FIXTURE_TOKEN not in snaps["agent"].tail_text assert _REDACT_FIXTURE_TOKEN not in (snaps["agent"].full_text or "") def test_capture_default_log_snapshots_no_redact_passes_through( self, hermes_home_with_secret ): from hermes_cli.debug import _capture_default_log_snapshots snaps = _capture_default_log_snapshots(50, redact=False) assert _REDACT_FIXTURE_TOKEN in snaps["agent"].tail_text assert _REDACT_FIXTURE_TOKEN in (snaps["agent"].full_text or "") # --------------------------------------------------------------------------- # Debug report collection # --------------------------------------------------------------------------- class TestCollectDebugReport: """Test the debug report builder.""" def test_report_includes_dump_output(self, hermes_home): from hermes_cli.debug import collect_debug_report with patch("hermes_cli.dump.run_dump") as mock_dump: mock_dump.side_effect = lambda args: print( "--- hermes dump ---\nversion: 0.8.0\n--- end dump ---" ) report = collect_debug_report(log_lines=50) assert "--- hermes dump ---" in report assert "version: 0.8.0" in report # --------------------------------------------------------------------------- # CLI entry point — run_debug_share # --------------------------------------------------------------------------- class TestRunDebugShare: """Test the run_debug_share CLI handler.""" def test_share_sweeps_expired_pastes(self, hermes_home, capsys): """Slash-command path should sweep old pending deletes before uploading.""" from hermes_cli.debug import run_debug_share args = MagicMock() args.lines = 50 args.expire = 7 args.local = False args.nous = False with patch("hermes_cli.dump.run_dump"), \ patch("hermes_cli.debug._sweep_expired_pastes", return_value=(0, 0)) as mock_sweep, \ patch("hermes_cli.debug.upload_to_pastebin", return_value="https://paste.rs/test"): run_debug_share(args) mock_sweep.assert_called_once() assert "Debug report uploaded" in capsys.readouterr().out def test_share_uploads_five_pastes(self, hermes_home, capsys): """Successful share uploads report + agent.log + gateway.log + gui.log + desktop.log.""" from hermes_cli.debug import run_debug_share args = MagicMock() args.lines = 50 args.expire = 7 args.local = False args.nous = False call_count = [0] uploaded_content = [] def _mock_upload(content, expiry_days=7): call_count[0] += 1 uploaded_content.append(content) return f"https://paste.rs/paste{call_count[0]}" with patch("hermes_cli.dump.run_dump") as mock_dump, \ patch("hermes_cli.debug.upload_to_pastebin", side_effect=_mock_upload): mock_dump.side_effect = lambda a: print("--- hermes dump ---\nversion: test\n--- end dump ---") run_debug_share(args) out = capsys.readouterr().out # Should have 5 uploads: report, agent.log, gateway.log, gui.log, desktop.log assert call_count[0] == 5 assert "paste.rs/paste1" in out # Report assert "paste.rs/paste2" in out # agent.log assert "paste.rs/paste3" in out # gateway.log assert "paste.rs/paste4" in out # gui.log assert "paste.rs/paste5" in out # desktop.log assert "Report" in out assert "agent.log" in out assert "gateway.log" in out assert "gui.log" in out assert "desktop.log" in out # Each log paste should start with the dump header agent_paste = uploaded_content[1] assert "--- hermes dump ---" in agent_paste assert "--- full agent.log ---" in agent_paste gateway_paste = uploaded_content[2] assert "--- hermes dump ---" in gateway_paste assert "--- full gateway.log ---" in gateway_paste gui_paste = uploaded_content[3] assert "--- hermes dump ---" in gui_paste assert "--- full gui.log ---" in gui_paste desktop_paste = uploaded_content[4] assert "--- hermes dump ---" in desktop_paste assert "--- full desktop.log ---" in desktop_paste # --------------------------------------------------------------------------- # Share-time redaction wiring + visible banner # --------------------------------------------------------------------------- class TestRunDebugShareRedaction: """End-to-end: --no-redact flag, banner injection, default behavior.""" @pytest.fixture def hermes_home_with_secret(self, tmp_path, monkeypatch): """Isolated HERMES_HOME whose agent.log contains a vendor-prefixed token.""" home = tmp_path / ".hermes" home.mkdir() monkeypatch.setenv("HERMES_HOME", str(home)) monkeypatch.delenv("HERMES_REDACT_SECRETS", raising=False) logs_dir = home / "logs" logs_dir.mkdir() (logs_dir / "agent.log").write_text( f"2026-04-12 17:00:00 INFO config: api_key={_REDACT_FIXTURE_TOKEN} loaded\n" ) (logs_dir / "errors.log").write_text("") (logs_dir / "gateway.log").write_text( f"2026-04-12 17:00:01 INFO gateway.run: token {_REDACT_FIXTURE_TOKEN}\n" ) return home def test_default_share_redacts_uploaded_content( self, hermes_home_with_secret, capsys ): """The uploaded report and full-log pastes do not contain the raw token.""" from hermes_cli.debug import run_debug_share args = MagicMock() args.lines = 50 args.expire = 7 args.local = False args.nous = False args.no_redact = False captured: list[str] = [] def fake_upload(content, expiry_days=7): captured.append(content) return f"https://paste.rs/{len(captured)}" with patch("hermes_cli.dump.run_dump"), \ patch("hermes_cli.debug._sweep_expired_pastes", return_value=(0, 0)), \ patch("hermes_cli.debug.upload_to_pastebin", side_effect=fake_upload): run_debug_share(args) # At least the report plus one full log paste reached the upload path. assert len(captured) >= 2 for content in captured: assert _REDACT_FIXTURE_TOKEN not in content, ( "raw token leaked into upload-bound content" ) def test_default_share_includes_redaction_banner( self, hermes_home_with_secret, capsys ): """Each upload-bound paste carries the visible redaction banner.""" from hermes_cli.debug import run_debug_share args = MagicMock() args.lines = 50 args.expire = 7 args.local = False args.nous = False args.no_redact = False captured: list[str] = [] def fake_upload(content, expiry_days=7): captured.append(content) return f"https://paste.rs/{len(captured)}" with patch("hermes_cli.dump.run_dump"), \ patch("hermes_cli.debug._sweep_expired_pastes", return_value=(0, 0)), \ patch("hermes_cli.debug.upload_to_pastebin", side_effect=fake_upload): run_debug_share(args) for content in captured: assert "redacted at upload time" in content, ( "redaction banner missing from upload-bound content" ) def test_no_redact_flag_disables_redaction_and_banner( self, hermes_home_with_secret, capsys ): """--no-redact preserves original log content and omits the banner.""" from hermes_cli.debug import run_debug_share args = MagicMock() args.lines = 50 args.expire = 7 args.local = False args.nous = False args.no_redact = True captured: list[str] = [] def fake_upload(content, expiry_days=7): captured.append(content) return f"https://paste.rs/{len(captured)}" with patch("hermes_cli.dump.run_dump"), \ patch("hermes_cli.debug._sweep_expired_pastes", return_value=(0, 0)), \ patch("hermes_cli.debug.upload_to_pastebin", side_effect=fake_upload): run_debug_share(args) # The agent.log paste should now contain the raw token. assert any(_REDACT_FIXTURE_TOKEN in c for c in captured), ( "expected raw token in --no-redact upload" ) # No banner anywhere when redaction is disabled. for content in captured: assert "redacted at upload time" not in content, ( "banner present with --no-redact" ) # --------------------------------------------------------------------------- # run_debug router # --------------------------------------------------------------------------- class TestRunDebug: def test_no_subcommand_shows_usage(self, capsys): from hermes_cli.debug import run_debug args = MagicMock() args.debug_command = None run_debug(args) out = capsys.readouterr().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 args = MagicMock() args.debug_command = "share" args.lines = 200 args.expire = 7 args.local = True args.nous = False with patch("hermes_cli.dump.run_dump"): run_debug(args) # --------------------------------------------------------------------------- # Argparse integration # --------------------------------------------------------------------------- # --------------------------------------------------------------------------- # 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_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 class TestScheduleAutoDelete: """``_schedule_auto_delete`` used to spawn a detached Python subprocess per call (one per paste URL batch). Those subprocesses slept 6 hours and accumulated forever under repeated use — 15+ orphaned interpreters were observed in production. The new implementation is stateless: it records pending deletions to ``~/.hermes/pastes/pending.json`` and lets ``_sweep_expired_pastes`` handle the DELETE requests synchronously on the next ``hermes debug`` invocation. """ def test_records_pending_to_json(self, hermes_home): """Scheduled URLs are persisted to pending.json with expiration.""" from hermes_cli.debug import _schedule_auto_delete, _pending_file import json _schedule_auto_delete( ["https://paste.rs/abc", "https://paste.rs/def"], delay_seconds=10, ) pending_path = _pending_file() assert pending_path.exists() entries = json.loads(pending_path.read_text()) assert len(entries) == 2 urls = {e["url"] for e in entries} assert urls == {"https://paste.rs/abc", "https://paste.rs/def"} # expire_at is ~now + delay_seconds import time for e in entries: assert e["expire_at"] > time.time() assert e["expire_at"] <= time.time() + 15 def test_dedupes_same_url(self, hermes_home): """Same URL recorded twice → one entry with the later expire_at.""" from hermes_cli.debug import _schedule_auto_delete, _load_pending _schedule_auto_delete(["https://paste.rs/dup"], delay_seconds=10) _schedule_auto_delete(["https://paste.rs/dup"], delay_seconds=100) entries = _load_pending() assert len(entries) == 1 assert entries[0]["url"] == "https://paste.rs/dup" class TestSweepExpiredPastes: """Test the opportunistic sweep that replaces the sleeping subprocess.""" def test_sweep_deletes_expired_entries(self, hermes_home): from hermes_cli.debug import ( _sweep_expired_pastes, _save_pending, _load_pending, ) import time # Seed pending.json with one expired + one future entry _save_pending([ {"url": "https://paste.rs/expired", "expire_at": time.time() - 100}, {"url": "https://paste.rs/future", "expire_at": time.time() + 3600}, ]) delete_calls = [] def fake_delete(url): delete_calls.append(url) return True with patch("hermes_cli.debug.delete_paste", side_effect=fake_delete): deleted, remaining = _sweep_expired_pastes() assert delete_calls == ["https://paste.rs/expired"] assert deleted == 1 assert remaining == 1 entries = _load_pending() urls = {e["url"] for e in entries} assert urls == {"https://paste.rs/future"} def test_sweep_leaves_future_entries_alone(self, hermes_home): from hermes_cli.debug import _sweep_expired_pastes, _save_pending import time _save_pending([ {"url": "https://paste.rs/future1", "expire_at": time.time() + 3600}, {"url": "https://paste.rs/future2", "expire_at": time.time() + 7200}, ]) with patch("hermes_cli.debug.delete_paste") as mock_delete: deleted, remaining = _sweep_expired_pastes() mock_delete.assert_not_called() assert deleted == 0 assert remaining == 2 def test_sweep_survives_network_failure(self, hermes_home): """Failed DELETEs stay in pending.json until the 24h grace window.""" from hermes_cli.debug import ( _sweep_expired_pastes, _save_pending, _load_pending, ) import time _save_pending([ {"url": "https://paste.rs/flaky", "expire_at": time.time() - 100}, ]) with patch( "hermes_cli.debug.delete_paste", side_effect=Exception("network down"), ): deleted, remaining = _sweep_expired_pastes() # Failure within 24h grace → kept for retry assert deleted == 0 assert remaining == 1 assert len(_load_pending()) == 1 class TestRunDebugSweepsOnInvocation: """``run_debug`` must sweep expired pastes on every invocation.""" def test_run_debug_calls_sweep(self, hermes_home): from hermes_cli.debug import run_debug args = MagicMock() args.debug_command = None # default → prints help with patch("hermes_cli.debug._sweep_expired_pastes") as mock_sweep: run_debug(args) mock_sweep.assert_called_once() class TestRunDebugDelete: 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 class TestShareIncludesAutoDelete: """Verify that run_debug_share schedules auto-deletion and prints TTL.""" 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 args.nous = 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 assert "NOT redacted" 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_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] # --------------------------------------------------------------------------- # Shared bundle collection + Nous-S3 path # --------------------------------------------------------------------------- class TestCollectShareBundle: def test_no_redact_omits_banner(self, hermes_home): from hermes_cli.debug import collect_share_bundle with patch("hermes_cli.dump.run_dump"): bundle = collect_share_bundle(log_lines=50, redact=False) assert "redacted at upload time" not in bundle["report"] def test_redaction_keeps_secrets_out(self, hermes_home): from hermes_cli.debug import collect_share_bundle secret = "sk-proj-abcdefghijklmnopqrstuvwxyz1234567890" (hermes_home / "logs" / "agent.log").write_text( f"line one\nOPENAI_API_KEY={secret}\nline three\n" ) with patch("hermes_cli.dump.run_dump"): redacted = collect_share_bundle(log_lines=50, redact=True) unredacted = collect_share_bundle(log_lines=50, redact=False) # Sanity: without redaction the secret is present in the bundle. assert secret in "\n".join(unredacted.values()) # With redaction it must be scrubbed everywhere. assert secret not in "\n".join(redacted.values()) class TestBuildNousBundle: def test_envelope_shape_and_gzip(self, hermes_home): import gzip import json as _json from hermes_cli.debug import build_nous_bundle files = {"report": "hello", "agent.log": "log line"} blob = build_nous_bundle(files, redact=True) # It's gzip — magic bytes. assert blob[:2] == b"\x1f\x8b" envelope = _json.loads(gzip.decompress(blob).decode()) assert envelope["format"] == "hermes-debug-share/1" assert envelope["redacted"] is True assert envelope["files"] == files assert "created" in envelope def test_redacted_false_recorded(self): import gzip import json as _json from hermes_cli.debug import build_nous_bundle blob = build_nous_bundle({"report": "x"}, redact=False) envelope = _json.loads(gzip.decompress(blob).decode()) assert envelope["redacted"] is False class TestRunDebugShareNous: def _args(self, **over): class _A: lines = 50 expire = 7 local = False nous = True no_redact = False yes = True a = _A() for k, v in over.items(): setattr(a, k, v) return a def test_nous_success_prints_view_url(self, hermes_home, capsys): from hermes_cli.debug import run_debug_share res = { "id": "id-1", "viewUrl": "https://support.example.com/diagnostics/id-1", "expiresAt": "2026-06-20T00:00:00Z", } with patch("hermes_cli.dump.run_dump"), patch( "hermes_cli.diagnostics_upload.share_to_nous", return_value=res ) as share: run_debug_share(self._args()) out = capsys.readouterr().out assert "Nous-INTERNAL" in out assert "https://support.example.com/diagnostics/id-1" in out assert "2026-06-20T00:00:00Z" in out # The blob passed to share_to_nous must be gzip bytes. blob = share.call_args[0][0] assert isinstance(blob, (bytes, bytearray)) and blob[:2] == b"\x1f\x8b" def test_nous_failure_suggests_local(self, hermes_home, capsys): from hermes_cli.debug import run_debug_share with patch("hermes_cli.dump.run_dump"), patch( "hermes_cli.diagnostics_upload.share_to_nous", side_effect=RuntimeError("service down"), ): with pytest.raises(SystemExit) as exc: run_debug_share(self._args()) assert exc.value.code == 1 err = capsys.readouterr().err assert "Nous upload failed" in err assert "--local" in err def test_nous_does_not_touch_pastebin(self, hermes_home): from hermes_cli.debug import run_debug_share res = {"id": "id-1", "viewUrl": "https://v"} with patch("hermes_cli.dump.run_dump"), patch( "hermes_cli.diagnostics_upload.share_to_nous", return_value=res ), patch("hermes_cli.debug.upload_to_pastebin") as paste: run_debug_share(self._args()) paste.assert_not_called() class TestDebugSlashCommand: """`/debug [nous|local]` parsing in the CLI/TUI handler. The classic CLI and the TUI slash worker both dispatch through ``HermesCLI.process_command`` → ``_handle_debug_command(cmd_original)``, which parses an optional destination word and builds the args namespace handed to ``run_debug_share``. """ def _handler(self): from hermes_cli.cli_commands_mixin import CLICommandsMixin class _Stub(CLICommandsMixin): pass return _Stub()._handle_debug_command def _captured(self, cmd_original): captured = {} def _fake_run(args): captured.update(vars(args)) with patch("hermes_cli.debug.run_debug_share", _fake_run): self._handler()(cmd_original) return captured def test_bare_debug_defaults_to_paste(self): c = self._captured("/debug") assert c["nous"] is False and c["local"] is False assert c["lines"] == 200 and c["expire"] == 7 # The slash command IS the consent action → skip the [y/N] prompt # (input() would hang inside prompt_toolkit's event loop). assert c["yes"] is True def test_word_parsing_is_case_insensitive(self): c = self._captured("/debug NOUS") assert c["nous"] is True def test_no_arg_default_keyword(self): # Calling with no cmd_original (legacy callers) must still work. c = self._captured("") assert c["nous"] is False and c["local"] is False class TestShareConsentGate: """`hermes debug share` requires explicit consent before uploading. Uses SimpleNamespace rather than MagicMock so ``args.yes`` is a real ``False`` — a MagicMock auto-provides a truthy ``.yes`` and would silently bypass the very gate under test. """ def _args(self, **over): from types import SimpleNamespace base = dict(lines=50, expire=7, local=False, nous=False, no_redact=False, yes=False) base.update(over) return SimpleNamespace(**base) def test_non_interactive_requires_yes(self, hermes_home, capsys, monkeypatch): """No TTY + no --yes → exit(1), never upload silently.""" from hermes_cli.debug import run_debug_share monkeypatch.setattr("sys.stdin.isatty", lambda: False) with patch("hermes_cli.dump.run_dump"), \ patch("hermes_cli.debug.upload_to_pastebin") as mock_upload: with pytest.raises(SystemExit) as exc: run_debug_share(self._args()) assert exc.value.code == 1 mock_upload.assert_not_called() err = capsys.readouterr().err assert "Non-interactive mode requires --yes" in err assert "personal data" in err def test_local_never_prompts(self, hermes_home, capsys, monkeypatch): """--local renders to stdout and must not prompt or upload.""" from hermes_cli.debug import run_debug_share def _boom(_): raise AssertionError("input() must not be called for --local") monkeypatch.setattr("builtins.input", _boom) with patch("hermes_cli.dump.run_dump"), \ patch("hermes_cli.debug.upload_to_pastebin") as mock_upload: run_debug_share(self._args(local=True)) mock_upload.assert_not_called() assert "Aborted" not in capsys.readouterr().out