mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
Second, deeper pass over tools/gateway/hermes_cli plus first pass over the trees wave 1 missed (acp, acp_adapter, skills, computer_use, docker, dashboard, conformance, monitoring, secret_sources, hermes_state, providers). Same rubric as wave 1 (AGENTS.md test policy); security, alternation/caching invariants, issue-number regressions, and E2E kept. Real test-quality fixes found and rooted out along the way: - tests/tools/test_command_guards.py made real auxiliary-LLM HTTPS calls (DEFAULT_CONFIG smart-approval leaked in) — pinned approval mode=manual via autouse fixture: 17.4s → 0.4s. - test_model_switch_custom_providers.py / test_user_providers_model_switch.py silently probed live provider catalogs (~2s/test) — stubbed cached_provider_model_ids/provider_model_ids/fetch_api_models. - test_telegram_noise_filter.py: 15-platform copy-paste matrix over shared gateway.run logic → 3 representative platforms (55s → 3.9s). - test_gateway_shutdown.py: stop()'s 5s interrupt-deadline loop spun on MagicMock agents — interrupt.side_effect now clears _running_agents (22s → 1.0s). - test_gateway_inactivity_timeout.py poll-harness timings shrunk 3-5x (24s → 1.1s); test_mcp_stability.py backoff/SIGTERM-grace sleeps patched (15.4s → 2.5s); test_async_delegation.py negative-drain wait 5s → 0.5s. - test_telegram_init_deadline.py: loop-block margin restored to 1.0s with rationale comment — the watchdog-dump assertion needs the loop blocked well past deadline+grace under parallel load (flaked once in the 40-worker verification run at a 0.2s margin). Verification: full hermetic suite via scripts/run_tests.sh — 2,438 files, 21,718 tests passed, 0 failed, 293.9s wall. Suite totals vs original baseline: 46,820 → 19,757 test functions (−57.8%), wall 583.5s → 293.9s (−50%), subprocess CPU 13,564s → 11,623s.
1044 lines
37 KiB
Python
1044 lines
37 KiB
Python
"""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
|
|
|