Files
hermes-agent/tests/hermes_cli/test_debug.py
FroRaut b8a8be185d fix(debug): mask URL tokens and passwords in debug share uploads
hermes debug share, gateway /debug, the dashboard share and the desktop
diagnostics upload sent credential query parameters (?token=, ?api_key=,
?access_token=) and user:pass@ userinfo from logged URLs unchanged, to
the paste service or to Nous storage. Log lines keep these values
because log-time redaction leaves web URLs alone, and Firecrawl, for
example, logs the full URL it scrapes at INFO.

The cause is hermes_cli/debug.py:_redact_log_text, the one redactor on
every share path. It called redact_sensitive_text(force=True) without
redact_url_credentials=True, so the strict URL pass that compaction,
memory context, cron errors and the kanban notifier already use never
ran on the upload copy.

Pass redact_url_credentials=True there. Parameter names, hosts and
paths stay readable; values of credential-named parameters and URL
userinfo become ***.

(cherry picked from commit f522171b836bfd842354f5a34c8c2bdd46b39467)
2026-09-29 07:51:44 -04:00

1078 lines
41 KiB
Python

"""Tests for ``hermes debug`` CLI command and debug utilities."""
import os
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 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_bytes((line * num_lines).encode("utf-8"))
# 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
class TestMissingLogNote:
"""A missing log explains itself when the writer isn't this backend.
`hermes debug share` runs on the backend, so a desktop connected to a
remote/docker/SSH backend can never contribute desktop.log. Reporting a
bare absence sends triage after a client-side bug it cannot see.
"""
def test_backend_written_log_reports_plain_absence(self, hermes_home):
from hermes_cli.debug import _capture_log_snapshot
(hermes_home / "logs" / "agent.log").unlink()
snap = _capture_log_snapshot("agent", tail_lines=10)
assert snap.full_text is None
assert snap.tail_text == "(file not found)"
def test_client_written_log_names_its_writer_and_path(self, hermes_home):
from hermes_cli.debug import _capture_log_snapshot
(hermes_home / "logs" / "desktop.log").unlink()
snap = _capture_log_snapshot("desktop", tail_lines=10)
assert snap.full_text is None
assert "not on this host" in snap.tail_text
# The reader needs the path to collect by hand on the client machine.
assert str(hermes_home / "logs" / "desktop.log") in snap.tail_text
def test_present_client_log_is_captured_normally(self, hermes_home):
"""A local backend still reads desktop.log — the note is only for a miss."""
from hermes_cli.debug import _capture_log_snapshot
snap = _capture_log_snapshot("desktop", tail_lines=10)
assert "backend spawned" in snap.tail_text
assert "not on this host" not in snap.tail_text
def test_empty_client_log_is_empty_not_absent(self, hermes_home):
"""An empty file means the app ran and logged nothing — a different fact."""
from hermes_cli.debug import _capture_log_snapshot
(hermes_home / "logs" / "desktop.log").write_text("")
snap = _capture_log_snapshot("desktop", tail_lines=10)
assert snap.tail_text == "(file empty)"
def test_report_carries_the_note_for_a_remote_backend(self, hermes_home):
"""The uploaded report — what people paste into support — must explain it."""
from hermes_cli.debug import collect_debug_report
(hermes_home / "logs" / "desktop.log").unlink()
report = collect_debug_report(log_lines=10, dump_text="dump\n")
assert "--- desktop.log" in report
assert "not on this host" in report
# ---------------------------------------------------------------------------
# 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_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 "")
# ---------------------------------------------------------------------------
# Debug report collection
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# CLI entry point — run_debug_share
# ---------------------------------------------------------------------------
class TestRunDebugShare:
"""Test the run_debug_share CLI handler."""
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_fallback_provider_key_never_reaches_upload_bound_content(self, hermes_home_with_secret):
"""``hermes dump`` quotes ``fallback_providers`` from config.yaml; neither the CLI share
bundle nor the gateway /debug report may carry a fallback entry's api_key."""
from hermes_cli.debug import _capture_dump, collect_debug_report, collect_share_bundle
fallback_key = "fbk-opaque-0123456789abcdefghij"
(hermes_home_with_secret / "config.yaml").write_text(
"fallback_providers:\n"
" - provider: custom\n"
" model: backup-model\n"
" base_url: https://backup.example/v1\n"
f" api_key: {fallback_key}\n", encoding="utf-8")
uploads = [*collect_share_bundle(log_lines=20).values(),
collect_debug_report(log_lines=20, dump_text=_capture_dump())]
assert any("backup-model" in text for text in uploads) # the dump really ran
assert not [text for text in uploads if fallback_key in text]
@pytest.mark.parametrize(("yaml_value", "secret"), [
("87419362508741936250", "87419362508741936250"), # unquoted: YAML int
("'opaque***Fallback0123456789'", "opaque***Fallback0123456789"), # looks pre-masked
("{value: nestedOpaque0123456789abcdef}", "nestedOpaque0123456789abcdef"),
])
def test_fallback_api_key_masked_whatever_its_yaml_shape(self, hermes_home_with_secret, yaml_value, secret):
"""The runtime ``str()``s any ``api_key`` value (fallback_config.resolve_entry_api_key), so the
dump masks the field itself rather than relying on text redaction to recognise the value."""
from hermes_cli.debug import _capture_dump, collect_debug_report, collect_share_bundle
(hermes_home_with_secret / "config.yaml").write_text(
"fallback_providers:\n"
" - provider: custom\n"
" model: backup-model\n"
f" api_key: {yaml_value}\n", encoding="utf-8")
texts = [_capture_dump(redact=False), # `hermes dump` stdout, as printed
*collect_share_bundle(log_lines=20).values(),
collect_debug_report(log_lines=20, dump_text=_capture_dump())]
assert "backup-model" in texts[0]
assert not [text for text in texts if secret in text]
@pytest.mark.parametrize(("field", "yaml_value", "secret"), [
("token", "87419362508741936250", "87419362508741936250"),
("auth_token", "'opaque***AuthToken0123456789'", "opaque***AuthToken0123456789"),
("client_secret", "{value: clientSecretOpaque0123456789}", "clientSecretOpaque0123456789"),
("password", "98127364509812736450", "98127364509812736450"),
("clientApiKey", "'opaque***CamelKey0123456789'", "opaque***CamelKey0123456789"),
])
def test_fallback_secret_fields_masked_by_repo_policy(self, hermes_home_with_secret, field, yaml_value, secret):
"""Every field ``agent.redact`` treats as a credential is masked by field, not only ``api_key``;
env-var names and token budgets stay readable."""
from hermes_cli.debug import _capture_dump, collect_debug_report, run_debug_share
(hermes_home_with_secret / "config.yaml").write_text(
"fallback_providers:\n"
" - provider: custom\n"
" model: backup-model\n"
" key_env: BACKUP_KEY_ENV_NAME\n"
" api_key_env: BACKUP_API_KEY_ENV_NAME\n"
" max_tokens: 4321\n"
f" {field}: {yaml_value}\n", encoding="utf-8")
uploaded: list[str] = []
args = MagicMock(lines=20, expire=1, local=False, nous=False, no_redact=False)
with patch("hermes_cli.debug._sweep_expired_pastes", return_value=(0, 0)), \
patch("hermes_cli.debug._schedule_auto_delete"), \
patch("hermes_cli.debug.upload_to_pastebin",
side_effect=lambda content, expiry_days=1: uploaded.append(content) or "https://paste.rs/x"):
run_debug_share(args) # what actually reaches the paste service
assert any("backup-model" in text for text in uploaded) # the dump really reached the sink
assert not [text for text in uploaded if secret in text] # checked first: the sink alone catches a leak
texts = [_capture_dump(redact=False), # `hermes dump` stdout, as printed
collect_debug_report(log_lines=20, dump_text=_capture_dump())]
assert all(s in texts[0] for s in ("backup-model", "BACKUP_KEY_ENV_NAME", "BACKUP_API_KEY_ENV_NAME", "4321"))
assert not [text for text in texts if secret in text]
@pytest.mark.parametrize("base_url", [
"https://user:{s}@backup.example/v1",
"https://backup.example/v1?key={s}",
"https://backup.example/v1?X-Amz-Signature={s}",
"https://backup.example/v1?X-Goog-Signature={s}",
"https://backup.example/v1?sv=2024-11-04&sig={s}",
"https://backup.example/v1#access_token={s}&view=public",
])
def test_fallback_base_url_credential_never_reaches_upload_bound_content(self, hermes_home_with_secret, base_url):
"""A fallback ``base_url`` can carry its credential in the URL (userinfo, query, fragment). The
dump is config made to be pasted, so ``hermes dump`` itself and every upload of it get strict
URL-credential redaction, not the log policy."""
from hermes_cli.debug import _capture_dump, collect_debug_report, collect_share_bundle
secret = "urlCredOpaque0123456789abcdef"
(hermes_home_with_secret / "config.yaml").write_text(
"fallback_providers:\n"
" - provider: custom\n"
" model: backup-model\n"
f" base_url: '{base_url.format(s=secret)}'\n", encoding="utf-8")
texts = [_capture_dump(redact=False), # `hermes dump` stdout, as printed
*collect_share_bundle(log_lines=20).values(),
collect_debug_report(log_lines=20, dump_text=_capture_dump())]
assert all("backup-model" in text and "backup.example" in text for text in texts[:2])
assert not [text for text in texts if secret in text]
def test_capture_dump_is_independent_strict_redaction_boundary(self):
"""A future dump formatting mistake cannot leak through debug upload paths."""
from hermes_cli.debug import _capture_dump
raw_key = "opaqueBoundaryKeyABC123456789"
raw_password = "boundaryPasswordABC123456789"
raw_signature = "boundarySignatureABC123456789"
def leaky_dump(_args):
print(
"{'api_key': '" + raw_key + "', "
"'base_url': 'https://user:" + raw_password
+ "@example.test/v1?X-Amz-Signature=" + raw_signature + "'}"
)
with patch("hermes_cli.dump.run_dump", side_effect=leaky_dump):
safe = _capture_dump()
raw = _capture_dump(redact=False)
for secret in (raw_key, raw_password, raw_signature):
assert secret not in safe
assert secret in raw
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
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# Argparse integration
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# Delete / auto-delete
# ---------------------------------------------------------------------------
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_dpaste_url_error_explains_no_delete(self):
"""dpaste.com pastes have no owner token, so the user must be told the
paste cannot be deleted and will expire on its own (#106164)."""
from hermes_cli.debug import delete_paste
with pytest.raises(ValueError):
delete_paste("https://dpaste.com/ABC123")
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
# ---------------------------------------------------------------------------
# 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_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())
def test_redaction_masks_url_credentials(self, hermes_home):
"""Log-time redaction leaves ``?token=`` and ``user:pass@`` in URLs for tool flows;
the upload must not carry them."""
from hermes_cli.debug import collect_share_bundle
query_token = "Q7fK2mZp9RtX4vLb8NcW1yHs"
password = "Pw7Kq2Lm9Xs4Vb"
(hermes_home / "logs" / "agent.log").write_text(
"2026-09-29 01:00:00 INFO plugins.web.firecrawl.provider: Firecrawl scraping: "
f"https://files.example.com/export.csv?token={query_token}&page=2\n"
f"2026-09-29 01:00:01 INFO agent: using proxy http://alice:{password}@10.0.0.5:3128\n"
)
with patch("hermes_cli.dump.run_dump"):
bundle = "\n".join(collect_share_bundle(log_lines=50, redact=True).values())
assert query_token not in bundle
assert password not in bundle
# Only the credential values go; the URLs stay readable.
assert "export.csv?token=***&page=2" in bundle
assert "alice:***@10.0.0.5:3128" in bundle
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 "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 "--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
# 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
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()
assert "--yes" in capsys.readouterr().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