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

@ -9,8 +9,16 @@ Currently supports:
``~/.hermes/logs/*.log`` are not leaked into
the public paste service. Pass ``--no-redact``
to disable.
Pass ``--nous`` to upload instead to Nous-internal
storage (AWS S3) via a signed URL minted by the
Nous account service: the bundle is private
(viewable only by Nous staff / allowlisted mods via
a Google-login-gated viewer) and auto-deletes after
14 days, rather than going to a public paste.
"""
import datetime
import gzip
import io
import json
import logging
@ -588,6 +596,104 @@ def collect_debug_report(
return buf.getvalue()
# ---------------------------------------------------------------------------
# Shared bundle collection (used by both the paste.rs and Nous-S3 paths)
# ---------------------------------------------------------------------------
# Bundle format identifier embedded in the Nous-S3 JSON envelope. The
# discord-support viewer keys off this string to parse the bundle.
_NOUS_BUNDLE_FORMAT = "hermes-debug-share/1"
def collect_share_bundle(
log_lines: int = 200,
redact: bool = True,
) -> dict[str, str]:
"""Collect the debug report + full logs as a label→text mapping.
Returns ``{"report": ..., "agent.log": ..., "gateway.log": ...,
"desktop.log": ...}`` where each value is the already-redacted (when
``redact`` is True) text that would be uploaded. Keys for logs that are
absent/empty are simply omitted.
This is the single source of collection + redaction shared by both
destinations: the paste.rs path (:func:`build_debug_share`) and the
Nous-S3 path (``--nous``). Centralising it guarantees the Nous bundle is
built from the *same* force-redacted snapshots as the public paste path
redaction is the safety boundary, so the Nous path must never see raw
logs.
The dump header is prepended to each full log (mirroring the historical
paste behaviour) so every file is self-contained, and the redaction
banner is prepended when ``redact`` is True.
"""
dump_text = _capture_dump()
log_snapshots = _capture_default_log_snapshots(log_lines, redact=redact)
report = collect_debug_report(
log_lines=log_lines,
dump_text=dump_text,
log_snapshots=log_snapshots,
)
agent_log = log_snapshots["agent"].full_text
gateway_log = log_snapshots["gateway"].full_text
gui_log = log_snapshots["gui"].full_text
desktop_log = log_snapshots["desktop"].full_text
# Prepend dump header to each full log so every file is self-contained.
if agent_log:
agent_log = dump_text + "\n\n--- full agent.log ---\n" + agent_log
if gateway_log:
gateway_log = dump_text + "\n\n--- full gateway.log ---\n" + gateway_log
if gui_log:
gui_log = dump_text + "\n\n--- full gui.log ---\n" + gui_log
if desktop_log:
desktop_log = dump_text + "\n\n--- full desktop.log ---\n" + desktop_log
# Visible banner so reviewers know redaction was applied at upload time.
if redact:
report = _REDACTION_BANNER + report
if agent_log:
agent_log = _REDACTION_BANNER + agent_log
if gateway_log:
gateway_log = _REDACTION_BANNER + gateway_log
if gui_log:
gui_log = _REDACTION_BANNER + gui_log
if desktop_log:
desktop_log = _REDACTION_BANNER + desktop_log
bundle: dict[str, str] = {"report": report}
if agent_log:
bundle["agent.log"] = agent_log
if gateway_log:
bundle["gateway.log"] = gateway_log
if gui_log:
bundle["gui.log"] = gui_log
if desktop_log:
bundle["desktop.log"] = desktop_log
return bundle
def build_nous_bundle(bundle: dict[str, str], redact: bool = True) -> bytes:
"""Gzip-compress a :func:`collect_share_bundle` mapping into the Nous envelope.
The JSON shape is what the discord-support viewer (Repo 3) parses::
{"format": "hermes-debug-share/1",
"redacted": <bool>,
"created": <iso8601>,
"files": {"report": ..., "agent.log": ..., ...}}
"""
created = datetime.datetime.now(datetime.timezone.utc).isoformat()
envelope = {
"format": _NOUS_BUNDLE_FORMAT,
"redacted": bool(redact),
"created": created,
"files": bundle,
}
return gzip.compress(json.dumps(envelope).encode("utf-8"))
# ---------------------------------------------------------------------------
# CLI entry points
# ---------------------------------------------------------------------------
@ -627,50 +733,18 @@ def build_debug_share(
"""
_best_effort_sweep_expired_pastes()
# Capture dump once — prepended to every paste for context.
# The dump is already redacted at extract time via dump.py:_redact;
# log_snapshots are redacted by _capture_default_log_snapshots when
# redact=True so credentials never reach the public paste service.
dump_text = _capture_dump()
log_snapshots = _capture_default_log_snapshots(log_lines, redact=redact)
# Collect the report + full logs (force-redacted when redact=True) via the
# shared collector so the paste.rs and Nous-S3 paths build identical,
# identically-redacted bundles. The dump header + redaction banner are
# applied inside collect_share_bundle.
bundle = collect_share_bundle(log_lines=log_lines, redact=redact)
if redact:
logger.info(
"hermes debug share: applied force-mode redaction to log snapshots before upload"
)
report = collect_debug_report(
log_lines=log_lines,
dump_text=dump_text,
log_snapshots=log_snapshots,
)
agent_log = log_snapshots["agent"].full_text
gateway_log = log_snapshots["gateway"].full_text
gui_log = log_snapshots["gui"].full_text
desktop_log = log_snapshots["desktop"].full_text
# Prepend dump header to each full log so every paste is self-contained.
if agent_log:
agent_log = dump_text + "\n\n--- full agent.log ---\n" + agent_log
if gateway_log:
gateway_log = dump_text + "\n\n--- full gateway.log ---\n" + gateway_log
if gui_log:
gui_log = dump_text + "\n\n--- full gui.log ---\n" + gui_log
if desktop_log:
desktop_log = dump_text + "\n\n--- full desktop.log ---\n" + desktop_log
# Visible banner so reviewers reading the public paste know redaction
# was applied at upload time. Banner is omitted under --no-redact.
if redact:
report = _REDACTION_BANNER + report
if agent_log:
agent_log = _REDACTION_BANNER + agent_log
if gateway_log:
gateway_log = _REDACTION_BANNER + gateway_log
if gui_log:
gui_log = _REDACTION_BANNER + gui_log
if desktop_log:
desktop_log = _REDACTION_BANNER + desktop_log
report = bundle["report"]
urls: dict[str, str] = {}
failures: list[str] = []
@ -678,13 +752,9 @@ def build_debug_share(
# 1. Summary report (required — raises on failure so callers can fall back)
urls["Report"] = upload_to_pastebin(report, expiry_days=expiry)
# 2-4. Full logs (optional — failures are collected, not raised)
for label, content in (
("agent.log", agent_log),
("gateway.log", gateway_log),
("gui.log", gui_log),
("desktop.log", desktop_log),
):
# 2-5. Full logs (optional — failures are collected, not raised)
for label in ("agent.log", "gateway.log", "gui.log", "desktop.log"):
content = bundle.get(label)
if not content:
continue
try:
@ -709,49 +779,24 @@ def run_debug_share(args):
log_lines = getattr(args, "lines", 200)
expiry = getattr(args, "expire", 7)
local_only = getattr(args, "local", False)
nous = getattr(args, "nous", False)
redact = not getattr(args, "no_redact", False)
if local_only:
# Local-only path never uploads — render the report to stdout and bail
# before any network I/O. Mirrors the upload path's collection logic.
# before any network I/O. Reuses the shared collector so the rendered
# output matches exactly what would be uploaded.
_best_effort_sweep_expired_pastes()
print("Collecting debug report...")
dump_text = _capture_dump()
log_snapshots = _capture_default_log_snapshots(log_lines, redact=redact)
report = collect_debug_report(
log_lines=log_lines,
dump_text=dump_text,
log_snapshots=log_snapshots,
)
agent_log = log_snapshots["agent"].full_text
gateway_log = log_snapshots["gateway"].full_text
gui_log = log_snapshots["gui"].full_text
desktop_log = log_snapshots["desktop"].full_text
if agent_log:
agent_log = dump_text + "\n\n--- full agent.log ---\n" + agent_log
if gateway_log:
gateway_log = dump_text + "\n\n--- full gateway.log ---\n" + gateway_log
if gui_log:
gui_log = dump_text + "\n\n--- full gui.log ---\n" + gui_log
if desktop_log:
desktop_log = dump_text + "\n\n--- full desktop.log ---\n" + desktop_log
if redact:
report = _REDACTION_BANNER + report
if agent_log:
agent_log = _REDACTION_BANNER + agent_log
if gateway_log:
gateway_log = _REDACTION_BANNER + gateway_log
if gui_log:
gui_log = _REDACTION_BANNER + gui_log
if desktop_log:
desktop_log = _REDACTION_BANNER + desktop_log
print(report)
for title, body in (
("FULL agent.log", agent_log),
("FULL gateway.log", gateway_log),
("FULL gui.log", gui_log),
("FULL desktop.log", desktop_log),
bundle = collect_share_bundle(log_lines=log_lines, redact=redact)
print(bundle["report"])
for title, label in (
("FULL agent.log", "agent.log"),
("FULL gateway.log", "gateway.log"),
("FULL gui.log", "gui.log"),
("FULL desktop.log", "desktop.log"),
):
body = bundle.get(label)
if body:
print(f"\n\n{'=' * 60}")
print(title)
@ -759,6 +804,10 @@ def run_debug_share(args):
print(body)
return
if nous:
_run_debug_share_nous(log_lines=log_lines, redact=redact)
return
print(_PRIVACY_NOTICE)
print("Collecting debug report...")
print("Uploading...")
@ -792,6 +841,80 @@ def run_debug_share(args):
print(f"\nShare these links with the Hermes team for support.")
_NOUS_PRIVACY_NOTICE = """\
--nous: This uploads your debug bundle to Nous-INTERNAL storage (AWS S3),
NOT a public paste service. The following is included:
System info (OS, Python/Hermes version, provider, which API keys are
configured NOT the actual keys)
Full agent.log, gateway.log, and desktop.log (up to 512 KB each likely
contains conversation content, tool outputs, and file paths)
The bundle is viewable only by Nous staff (and allowlisted Discord mods)
via a Google-login-gated viewer.
It is NOT a public paste there is no public URL to the contents.
It auto-deletes after 14 days.
"""
def _run_debug_share_nous(*, log_lines: int, redact: bool) -> None:
"""Handle ``hermes debug share --nous``: upload the bundle to Nous-S3.
Collects the same force-redacted bundle as the paste path, gzips it into
the Nous envelope, requests a signed URL from NAS, uploads, and prints the
private viewer link. On any failure falls back to a clear error that
suggests ``--local``.
"""
from hermes_cli.diagnostics_upload import share_to_nous
print(_NOUS_PRIVACY_NOTICE)
if not redact:
print(
"⚠️ --no-redact is set: secrets in your logs will NOT be redacted "
"before upload.\n"
)
print("Collecting debug report...")
_best_effort_sweep_expired_pastes()
bundle = collect_share_bundle(log_lines=log_lines, redact=redact)
if redact:
logger.info(
"hermes debug share --nous: applied force-mode redaction before upload"
)
blob = build_nous_bundle(bundle, redact=redact)
print("Uploading to Nous diagnostics storage...")
try:
res = share_to_nous(blob)
except Exception as exc:
print(
f"\nNous upload failed: {exc}\n"
"\nThe Nous diagnostics service may be unavailable or not yet "
"provisioned.\n"
"Run `hermes debug share --local` to print the report instead, "
"or `hermes debug share` to upload to a public paste service.\n",
file=sys.stderr,
)
sys.exit(1)
view_url = res.get("viewUrl") or res.get("view_url")
print("\nDebug bundle uploaded to Nous (private):")
if view_url:
print(f" View URL {view_url}")
else:
print(f" (no view URL returned; upload id: {res.get('id', '?')})")
expires_at = res.get("expiresAt") or res.get("expires_at")
if expires_at:
print(f"\n⏱ Auto-deletes at {expires_at} (14-day retention).")
else:
print("\n⏱ Auto-deletes after 14 days.")
print(
"\nShare this private link with the Nous team — only Nous staff "
"(via Google login) can open it."
)
def run_debug_delete(args):
"""Delete one or more paste URLs uploaded by /debug."""
urls = getattr(args, "urls", [])
@ -842,6 +965,8 @@ def run_debug(args):
print(" --lines N Number of log lines to include (default: 200)")
print(" --expire N Paste expiry in days (default: 7)")
print(" --local Print report locally instead of uploading")
print(" --nous Upload to Nous-internal storage (private, staff-only,")
print(" auto-deletes in 14 days) instead of a public paste")
print(" --no-redact Disable upload-time secret redaction (default: redact)")
print()
print("Options (delete):")