"""Client for uploading ``hermes debug share`` bundles to Nous-internal S3. This is the opt-in (``--nous``) destination for ``hermes debug share``. Unlike the public paste.rs path, bundles uploaded here go to a Nous-owned S3 bucket via a short-lived signed URL minted by the Nous account service (NAS). The bucket auto-expires objects after 14 days, and the contents are only viewable by Nous staff (and allowlisted Discord mods) through a Google-OAuth-gated viewer. Flow: 1. POST {NAS_BASE}/api/diagnostics/upload-url → {uploadUrl, viewUrl, id, ...} (the request body carries ``sizeBytes``; NAS signs it into the presigned URL's ``ContentLength``, so the PUT must send exactly that many bytes) 2. PUT (the gzipped bundle, Content-Type application/gzip) NAS is stateless — the object's existence in S3 is the only state, so there is no confirm/callback step. Uses stdlib ``urllib`` only, matching ``debug.py`` style — no third-party deps. """ import json import os import urllib.request # Base URL of the Nous account service that mints the signed upload URL. # Overridable via env so the feature can be pointed at staging / a local dev # NAS instance during testing. NAS_BASE = os.environ.get( "HERMES_DIAGNOSTICS_BASE_URL", "https://portal.nousresearch.com" ) # Network timeout for each request (seconds). The upload itself can be larger # (a gzipped log bundle), so the PUT gets a more generous window. _REQUEST_TIMEOUT = 30 _UPLOAD_TIMEOUT = 120 _USER_AGENT = "hermes-agent/debug-share" def request_upload_url( content_type: str = "application/gzip", size_bytes: int | None = None, ) -> dict: """Ask NAS to mint a presigned PUT URL for a diagnostics bundle. POSTs a small JSON body to ``{NAS_BASE}/api/diagnostics/upload-url`` and returns the parsed JSON response, expected to contain at least ``uploadUrl``, ``viewUrl`` and ``id`` (plus optional ``expiresAt`` / ``uploadExpiresInSeconds``). Raises on non-2xx responses or unparseable JSON. """ payload: dict = {"contentType": content_type} if size_bytes is not None: payload["sizeBytes"] = int(size_bytes) data = json.dumps(payload).encode("utf-8") req = urllib.request.Request( f"{NAS_BASE}/api/diagnostics/upload-url", data=data, method="POST", headers={ "Content-Type": "application/json", "Accept": "application/json", "User-Agent": _USER_AGENT, }, ) with urllib.request.urlopen(req, timeout=_REQUEST_TIMEOUT) as resp: status = getattr(resp, "status", None) if status is None: status = resp.getcode() if not (200 <= status < 300): raise RuntimeError( f"diagnostics upload-url request failed: HTTP {status}" ) body = resp.read().decode("utf-8") try: result = json.loads(body) except (ValueError, json.JSONDecodeError) as exc: raise RuntimeError( f"diagnostics upload-url returned non-JSON response: {body[:200]}" ) from exc if not isinstance(result, dict) or not result.get("uploadUrl"): raise RuntimeError( "diagnostics upload-url response missing 'uploadUrl': " f"{body[:200]}" ) return result def put_bundle( upload_url: str, data: bytes, content_type: str = "application/gzip", ) -> None: """PUT the gzipped *data* bundle to a presigned *upload_url*. Sets the ``Content-Type`` header (must match what NAS pinned when signing the URL, otherwise S3 rejects the signature). Raises on non-2xx. """ req = urllib.request.Request( upload_url, data=data, method="PUT", headers={ "Content-Type": content_type, "User-Agent": _USER_AGENT, }, ) with urllib.request.urlopen(req, timeout=_UPLOAD_TIMEOUT) as resp: status = getattr(resp, "status", None) if status is None: status = resp.getcode() if not (200 <= status < 300): raise RuntimeError(f"diagnostics bundle PUT failed: HTTP {status}") def share_to_nous(report_bundle: bytes) -> dict: """Orchestrate the full Nous-S3 upload of a gzipped *report_bundle*. Two steps: mint a presigned PUT URL (sending the exact ``sizeBytes`` NAS signs into the URL's ``ContentLength``), then PUT the bundle. NAS is stateless — the object's existence in S3 is the only state, so there is no confirm/callback step. Returns the dict from :func:`request_upload_url` (which carries ``viewUrl`` / ``id`` / expiry metadata) so the caller can print the viewer link. Raises on any failure of either step. """ size_bytes = len(report_bundle) info = request_upload_url( content_type="application/gzip", size_bytes=size_bytes ) put_bundle(info["uploadUrl"], report_bundle, content_type="application/gzip") return info