feat(debug): add --nous flag to upload diagnostics to Nous S3

`hermes debug share --nous` uploads the (force-redacted) debug bundle to
Nous-internal S3 storage via a presigned URL minted by the Nous account
service, instead of a public paste. The bundle is private — viewable only
by Nous staff / allowlisted mods through a Google-OAuth-gated viewer — and
auto-deletes after 14 days. The paste.rs path is unchanged and remains the
default.

- hermes_cli/diagnostics_upload.py (new): stdlib-urllib NAS client —
  request_upload_url(), put_bundle(), confirm_upload() (best-effort),
  share_to_nous() orchestrator. Base URL via HERMES_DIAGNOSTICS_BASE_URL
  (default https://portal.nousresearch.com).
- hermes_cli/debug.py: extract collect_share_bundle() from build_debug_share()
  so the Nous path reuses the exact same redaction/collection (paste.rs
  behaviour unchanged); add build_nous_bundle() producing the gzipped
  {"format":"hermes-debug-share/1","redacted":...,"files":...} envelope the
  discord-support viewer parses; add the --nous run path with a privacy
  notice and a clean fallback (suggest --local) on failure.
- hermes_cli/main.py: add the --nous flag + help/epilog entry on
  `debug share`.
- tests: test_diagnostics_upload.py (new) mocks urllib; test_debug.py adds
  bundle/Nous coverage. 97 passing.
This commit is contained in:
Ben Barclay 2026-06-06 04:34:55 +00:00 committed by Teknium
parent 4a7a6fd401
commit 51eeb70cb8
5 changed files with 815 additions and 81 deletions

View file

@ -503,6 +503,7 @@ class TestRunDebugShare:
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, \
@ -521,6 +522,7 @@ class TestRunDebugShare:
args.lines = 50
args.expire = 7
args.local = False
args.nous = False
with patch("hermes_cli.dump.run_dump"), \
patch(
@ -541,6 +543,7 @@ class TestRunDebugShare:
args.lines = 50
args.expire = 7
args.local = True
args.nous = False
with patch("hermes_cli.dump.run_dump"):
run_debug_share(args)
@ -558,6 +561,7 @@ class TestRunDebugShare:
args.lines = 50
args.expire = 7
args.local = False
args.nous = False
call_count = [0]
uploaded_content = []
@ -616,6 +620,7 @@ class TestRunDebugShare:
args.lines = 50
args.expire = 7
args.local = False
args.nous = False
uploaded_content = []
@ -661,6 +666,7 @@ class TestRunDebugShare:
args.lines = 50
args.expire = 7
args.local = False
args.nous = False
call_count = [0]
def _mock_upload(content, expiry_days=7):
@ -685,6 +691,7 @@ class TestRunDebugShare:
args.lines = 50
args.expire = 7
args.local = False
args.nous = False
call_count = [0]
def _mock_upload(content, expiry_days=7):
@ -711,6 +718,7 @@ class TestRunDebugShare:
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",
@ -759,6 +767,7 @@ class TestRunDebugShareRedaction:
args.lines = 50
args.expire = 7
args.local = False
args.nous = False
args.no_redact = False
captured: list[str] = []
@ -789,6 +798,7 @@ class TestRunDebugShareRedaction:
args.lines = 50
args.expire = 7
args.local = False
args.nous = False
args.no_redact = False
captured: list[str] = []
@ -817,6 +827,7 @@ class TestRunDebugShareRedaction:
args.lines = 50
args.expire = 7
args.local = False
args.nous = False
args.no_redact = True
captured: list[str] = []
@ -867,6 +878,7 @@ class TestRunDebug:
args.lines = 200
args.expire = 7
args.local = True
args.nous = False
with patch("hermes_cli.dump.run_dump"):
run_debug(args)
@ -1245,6 +1257,7 @@ class TestShareIncludesAutoDelete:
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",
@ -1267,6 +1280,7 @@ class TestShareIncludesAutoDelete:
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",
@ -1284,6 +1298,7 @@ class TestShareIncludesAutoDelete:
args.lines = 50
args.expire = 7
args.local = True
args.nous = False
with patch("hermes_cli.dump.run_dump"):
run_debug_share(args)
@ -1397,3 +1412,161 @@ class TestBuildDebugShare:
), patch("hermes_cli.debug._schedule_auto_delete"):
with pytest.raises(RuntimeError, match="all paste services down"):
build_debug_share(log_lines=50, redact=True)
# ---------------------------------------------------------------------------
# Shared bundle collection + Nous-S3 path
# ---------------------------------------------------------------------------
class TestCollectShareBundle:
def test_returns_report_and_logs(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=True)
assert "report" in bundle
assert "agent.log" in bundle
assert "gateway.log" in bundle
assert "desktop.log" in bundle
# Banner is prepended under redact=True.
assert "redacted at upload time" in bundle["report"]
assert "session started" in bundle["agent.log"]
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())
def test_build_debug_share_uses_collector(self, hermes_home):
# build_debug_share must produce the same report text the collector does
# (i.e. the refactor preserved paste.rs behaviour).
from hermes_cli.debug import build_debug_share, collect_share_bundle
with patch("hermes_cli.dump.run_dump"):
expected = collect_share_bundle(log_lines=50, redact=True)["report"]
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.urls["Report"] == "https://paste.rs/x"
# The report uploaded should match the collector's report.
assert uploaded[0] == expected
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
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()