agent register: surface structured hub failure reasons
Map the hub's structured `{detail: {reason}}` responses (invalid_key,
expired, revoked, already_consumed) and 429 rate-limits to actionable
CLI messages so users know to mint a fresh key from Settings → RIA
Agents instead of seeing a raw HTTPError.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
eb5b4ce839
commit
2f6b5ced18
|
|
@ -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,57 @@ from .namegen import generate_agent_name
|
|||
_LEGACY_ALIASES = {"--hub", "--key", "--name", "--device", "--insecure", "--log-level", "--config"}
|
||||
|
||||
|
||||
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 +93,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("/")
|
||||
|
|
@ -56,6 +111,14 @@ def _cmd_register(args: argparse.Namespace) -> int:
|
|||
try:
|
||||
with urllib.request.urlopen(req) 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
|
||||
|
|
@ -141,7 +204,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(
|
||||
|
|
|
|||
95
tests/agent/test_cli_register_errors.py
Normal file
95
tests/agent/test_cli_register_errors.py
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
"""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"<html>boom</html>")
|
||||
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_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()
|
||||
Loading…
Reference in New Issue
Block a user