diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bc13c0..d6c9027 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## [0.1.7] - 2026-05-26 + +### Added + +- **Human-readable agent names** — `ria-agent register` now generates a default `adjective-colour-animal` name (e.g. `swift-teal-falcon`) via the new `namegen` module when `--name` is omitted, instead of registering with an empty string. +- **Structured registration error messages** — `ria-agent register` translates hub responses into actionable English for the known failure reasons (`invalid_key`, `expired`, `revoked`, `already_consumed`) and rate-limit (`HTTP 429`) responses, instead of surfacing raw `HTTP 4xx` text. + +### Changed + +- **`ria-agent register` `--api-key` help** — now describes the personal `ria_reg_*` registration key flow (minted from **Settings → RIA Agents** on the hub, shown once at mint time). The legacy shared `[wac] API_KEY` is still accepted by the hub for back-compat, but the CLI documents the per-user flow as preferred. +- **`ria-agent register` success output** — now prints both the hub-assigned agent ID and the chosen name: `Registered agent: ()`. + +### Fixed + +- **`ria-agent register` blocked by Cloudflare on hubs behind it** — set an explicit `User-Agent` (`ria-agent/ (+https://riahub.ai/qoherent/ria-toolkit-oss)`) so the request isn't rejected as `Python-urllib/` (Cloudflare Browser Integrity Check returns HTTP 403, edge error code 1010). Version is read from package metadata so it tracks releases automatically. +- **`ria-agent register` could hang indefinitely** — added a 15-second timeout to the hub request; previously `urllib`'s default of no timeout meant a stuck hub would block the CLI forever. + +--- + ## [0.1.0] - 2026-02-20 ### Added diff --git a/poetry.lock b/poetry.lock index e947d9f..143af6e 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.3.4 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand. [[package]] name = "alabaster" @@ -1264,7 +1264,7 @@ files = [ [package.dependencies] attrs = ">=22.2.0" -jsonschema-specifications = ">=2023.3.6" +jsonschema-specifications = ">=2023.03.6" referencing = ">=0.28.4" rpds-py = ">=0.25.0" diff --git a/src/ria_toolkit_oss/agent/cli.py b/src/ria_toolkit_oss/agent/cli.py index 83a7769..37cb56a 100644 --- a/src/ria_toolkit_oss/agent/cli.py +++ b/src/ria_toolkit_oss/agent/cli.py @@ -5,8 +5,11 @@ Subcommands: - ``ria-agent run [legacy args]`` — legacy long-poll NodeAgent (unchanged). - ``ria-agent stream`` — new WebSocket-based IQ streamer. - ``ria-agent detect`` — print SDR drivers whose modules import cleanly. -- ``ria-agent register --hub URL --api-key KEY`` — register with the hub and - save credentials (and optional TX interlocks) to ``~/.ria/agent.json``. +- ``ria-agent register --hub URL --api-key KEY`` — register with the hub + using a personal registration key (minted from **Settings → RIA Agents** + on the hub, shown once at mint time) and save credentials (and optional + TX interlocks) to ``~/.ria/agent.json``. The hub also accepts the legacy + shared ``[wac] API_KEY`` for back-compat, but that path is deprecated. Invoking ``ria-agent`` with no subcommand falls through to the legacy long-poll behavior for back-compatibility with existing deployments. @@ -28,6 +31,79 @@ from .namegen import generate_agent_name _LEGACY_ALIASES = {"--hub", "--key", "--name", "--device", "--insecure", "--log-level", "--config"} +def _user_agent() -> str: + """Build the User-Agent header for hub requests. + + Set explicitly so we don't fall back to Python's default `Python-urllib/`, + which is blocked by Cloudflare's Browser Integrity Check on `riahub.ai` + (HTTP 403 edge code 1010). Version is read from package metadata so it + tracks releases instead of going stale. + """ + from importlib.metadata import PackageNotFoundError, version + + try: + pkg_version = version("ria-toolkit-oss") + except PackageNotFoundError: + pkg_version = "unknown" + return f"ria-agent/{pkg_version} (+https://riahub.ai/qoherent/ria-toolkit-oss)" + + +# How long to wait on the hub before giving up. The register endpoint is a +# small DB lookup + insert; anything past this is a stuck hub, not a slow one. +_REGISTER_TIMEOUT_S = 15 + + +REGISTRATION_REASON_MESSAGES = { + "invalid_key": ( + "Registration key not recognized. Generate a fresh key from " + "Settings → RIA Agents on the hub." + ), + "expired": ( + "This registration key has expired. Generate a new one from " + "Settings → RIA Agents on the hub." + ), + "revoked": ( + "This registration key was revoked. Generate a new one from " + "Settings → RIA Agents on the hub." + ), + "already_consumed": ( + "This single-use registration key has already been used. " + "Generate a new one, or mint a reusable key instead." + ), +} + + +def _explain_registration_failure(status: int, body: bytes) -> str: + """Return a human-readable explanation for a failed register call.""" + try: + parsed = json.loads(body) if body else None + except ValueError: + parsed = None + + if status == 429: + # 429 carries a plain string detail, never a reason code. + if isinstance(parsed, dict) and parsed.get("detail"): + detail = parsed["detail"] + else: + detail = body.decode("utf-8", "replace") or "rate limited" + return f"Registration rate-limited by the hub: {detail}" + + if not isinstance(parsed, dict): + text = body.decode("utf-8", "replace") + return f"HTTP {status}: {text or 'no body'}" + + detail = parsed.get("detail") + if isinstance(detail, dict): + reason = detail.get("reason") + if reason in REGISTRATION_REASON_MESSAGES: + return REGISTRATION_REASON_MESSAGES[reason] + if reason: + return f"Registration rejected ({reason})" + if isinstance(detail, str) and detail: + return f"Registration rejected: {detail}" + return f"HTTP {status}: {parsed}" + + def _cmd_detect(_args: argparse.Namespace) -> int: devices = available_devices() if not devices: @@ -39,6 +115,7 @@ def _cmd_detect(_args: argparse.Namespace) -> int: def _cmd_register(args: argparse.Namespace) -> int: + import urllib.error import urllib.request hub_url = args.hub.rstrip("/") @@ -51,11 +128,20 @@ def _cmd_register(args: argparse.Namespace) -> int: headers={ "Content-Type": "application/json", "X-API-Key": args.api_key, + "User-Agent": _user_agent(), }, ) try: - with urllib.request.urlopen(req) as resp: + with urllib.request.urlopen(req, timeout=_REGISTER_TIMEOUT_S) as resp: data = json.loads(resp.read()) + except urllib.error.HTTPError as e: + try: + err_body = e.read() + except Exception: + err_body = b"" + msg = _explain_registration_failure(e.code, err_body) + print(f"error: registration failed: {msg}", file=sys.stderr) + return 1 except Exception as e: print(f"error: registration failed: {e}", file=sys.stderr) return 1 @@ -80,7 +166,7 @@ def _cmd_register(args: argparse.Namespace) -> int: cfg.tx_allowed_freq_ranges = [[float(lo), float(hi)] for lo, hi in freq_ranges] path = _config.save(cfg) - print(f"Registered agent: {agent_id}") + print(f"Registered agent: {agent_id} ({name})") if cfg.tx_enabled: caps: list[str] = [] if cfg.tx_max_gain_db is not None: @@ -141,7 +227,16 @@ def main() -> None: p_reg = sub.add_parser("register", help="Register agent with RIA Hub and save credentials") p_reg.add_argument("--hub", required=True, help="RIA Hub URL (e.g. http://whitehorse:3005)") - p_reg.add_argument("--api-key", dest="api_key", required=True, help="Hub API key") + p_reg.add_argument( + "--api-key", + dest="api_key", + required=True, + help=( + "Personal registration key from the RIA Agents page on the hub " + "(format: ria_reg_...). Shown once when generated; save it then. " + "The legacy shared API key is also accepted but deprecated." + ), + ) p_reg.add_argument("--name", default=None, help="Human-friendly agent name") p_reg.add_argument("--insecure", action="store_true", help="Skip TLS verification") p_reg.add_argument( diff --git a/tests/agent/test_cli_register_errors.py b/tests/agent/test_cli_register_errors.py new file mode 100644 index 0000000..657dfb0 --- /dev/null +++ b/tests/agent/test_cli_register_errors.py @@ -0,0 +1,142 @@ +"""Structured error reporting for `ria-agent register` (T2).""" + +from __future__ import annotations + +import json +import sys +import urllib.error +from io import BytesIO +from unittest.mock import patch + +import pytest + +from ria_toolkit_oss.agent import cli as agent_cli + + +def _structured(reason: str) -> bytes: + return json.dumps({"detail": {"reason": reason}}).encode() + + +@pytest.mark.parametrize( + "reason", + ["invalid_key", "expired", "revoked", "already_consumed"], +) +def test_explain_maps_known_reasons(reason): + msg = agent_cli._explain_registration_failure(403, _structured(reason)) + assert msg == agent_cli.REGISTRATION_REASON_MESSAGES[reason] + + +def test_explain_unknown_reason_falls_through_with_code(): + msg = agent_cli._explain_registration_failure(403, _structured("brand_new_thing")) + assert "brand_new_thing" in msg + assert "rejected" in msg.lower() + + +def test_explain_string_detail(): + body = json.dumps({"detail": "Forbidden"}).encode() + msg = agent_cli._explain_registration_failure(403, body) + assert msg == "Registration rejected: Forbidden" + + +def test_explain_429_with_string_detail(): + body = json.dumps({"detail": "Too many attempts; try again shortly"}).encode() + msg = agent_cli._explain_registration_failure(429, body) + assert "rate-limited" in msg + assert "Too many attempts" in msg + + +def test_explain_429_with_no_body(): + msg = agent_cli._explain_registration_failure(429, b"") + assert "rate-limited" in msg + + +def test_explain_malformed_json(): + msg = agent_cli._explain_registration_failure(500, b"boom") + assert msg.startswith("HTTP 500") + assert "boom" in msg + + +def test_explain_empty_body(): + msg = agent_cli._explain_registration_failure(502, b"") + assert msg == "HTTP 502: no body" + + +def _http_error(status: int, body: bytes) -> urllib.error.HTTPError: + return urllib.error.HTTPError( + url="http://hub/screens/agents/register", + code=status, + msg="", + hdrs=None, # type: ignore[arg-type] + fp=BytesIO(body), + ) + + +def test_user_agent_is_set_and_not_python_default(): + """Cloudflare on `riahub.ai` returns 403 code 1010 to `Python-urllib/*`. + + Guarding the UA explicitly is the entire point of the register-flow fix; + if this test ever breaks, the production bug is back. + """ + ua = agent_cli._user_agent() + assert ua, "User-Agent must not be empty" + assert not ua.lower().startswith("python-urllib"), ( + f"User-Agent must not be Python's default (got {ua!r}) — Cloudflare blocks it" + ) + assert ua.startswith("ria-agent/") + + +def test_register_request_carries_explicit_user_agent(tmp_path): + """Capture the outbound urllib Request and verify the UA header is set.""" + cfg_path = tmp_path / "agent.json" + captured: dict = {} + + def _fake_urlopen(req, *args, **kwargs): + # urllib normalizes header names; get_header takes the title-cased form. + captured["ua"] = req.get_header("User-agent") + captured["api_key"] = req.get_header("X-api-key") + captured["timeout"] = kwargs.get("timeout") + raise urllib.error.HTTPError( + url=req.full_url, code=403, msg="", hdrs=None, # type: ignore[arg-type] + fp=BytesIO(_structured("invalid_key")), + ) + + with ( + patch.dict("os.environ", {"RIA_AGENT_CONFIG": str(cfg_path)}, clear=False), + patch("urllib.request.urlopen", side_effect=_fake_urlopen), + patch.object( + sys, + "argv", + ["ria-agent", "register", "--hub", "http://hub:3005", "--api-key", "ria_reg_x"], + ), + ): + with pytest.raises(SystemExit): + agent_cli.main() + + assert captured["ua"], "User-Agent header was not sent" + assert not captured["ua"].lower().startswith("python-urllib") + assert captured["api_key"] == "ria_reg_x" + assert captured["timeout"] is not None, "register must pass a timeout to urlopen" + + +def test_register_surfaces_reason_on_http_error(tmp_path, capsys): + cfg_path = tmp_path / "agent.json" + err = _http_error(403, _structured("revoked")) + + with ( + patch.dict("os.environ", {"RIA_AGENT_CONFIG": str(cfg_path)}, clear=False), + patch("urllib.request.urlopen", side_effect=err), + patch.object( + sys, + "argv", + ["ria-agent", "register", "--hub", "http://hub:3005", "--api-key", "ria_reg_x"], + ), + ): + with pytest.raises(SystemExit) as exc: + agent_cli.main() + + assert exc.value.code == 1 + captured = capsys.readouterr() + assert "revoked" in captured.err.lower() + assert "Settings → RIA Agents" in captured.err + # Config must NOT be written on failure. + assert not cfg_path.exists()