From 3750017c1f871522fd4046e04bc7b5350fa3543b Mon Sep 17 00:00:00 2001 From: jaylfc Date: Sun, 25 Oct 2026 14:06:12 +0000 Subject: [PATCH] tsk-jvzpbc: add admin-gated GET /api/orb/bridges route RED-FIRST: tests written first, run before the fix. Failing output: ``` FAILED tests/test_routes_orb.py::test_bridges_non_admin_session_gets_403 - AttributeError: 'module' object at tinyagentos.routes.orb has no attribute 'evaluate_all' FAILED tests/test_routes_orb.py::test_bridges_admin_one_orb_mimiclaw_target - assert 404 == 200 FAILED tests/test_routes_orb.py::test_bridges_admin_probe_transport_unavailable - assert 404 == 200 FAILED tests/test_routes_orb.py::test_bridges_admin_evaluate_all_passthrough - AttributeError: 'module' object at tinyagentos.routes.orb has no attribute 'evaluate_all' 4 failed, 17 passed ``` After the fix: 21 passed. Extract the shared LAN discovery body of orb_lan into _gather_lan() (returning lan_orbs, agent_buddy, errors with the same TransportUnavailable/ OSError degradation and re-raise semantics) and add GET /api/orb/bridges, which admin-gates first, gathers LAN, and returns evaluate_all() output. Docs-Reviewed: no doc change needed -- /api/orb/lan and /api/orb/status were not yet documented in docs/routes.d/, so the new /api/orb/bridges route has no compiled manual entry to add. --- changelog.d/tsk-jvzpbc-orb-bridges-route.md | 3 + tests/test_routes_orb.py | 78 +++++++++++++++++++++ tinyagentos/routes/orb.py | 31 ++++++-- 3 files changed, 108 insertions(+), 4 deletions(-) create mode 100644 changelog.d/tsk-jvzpbc-orb-bridges-route.md diff --git a/changelog.d/tsk-jvzpbc-orb-bridges-route.md b/changelog.d/tsk-jvzpbc-orb-bridges-route.md new file mode 100644 index 000000000..1101f5453 --- /dev/null +++ b/changelog.d/tsk-jvzpbc-orb-bridges-route.md @@ -0,0 +1,3 @@ +### Added + +- Add admin-gated GET /api/orb/bridges returning `evaluate_all()` over the shared `_gather_lan()` LAN discovery (tinyagentos/routes/orb.py). \ No newline at end of file diff --git a/tests/test_routes_orb.py b/tests/test_routes_orb.py index 96c13e2d9..e19a5b28b 100644 --- a/tests/test_routes_orb.py +++ b/tests/test_routes_orb.py @@ -192,3 +192,81 @@ def fake_probe(): monkeypatch.setattr("tinyagentos.routes.orb.probe_agent_buddy", fake_probe) resp = await client.get("/api/orb/lan") assert resp.status_code == 200 + + +# --------------------------------------------------------------------------- +# GET /api/orb/bridges (admin-gated, shared _gather_lan(), evaluate_all()) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_bridges_unauthed_requires_admin(unauthed_client): + resp = await unauthed_client.get("/api/orb/bridges") + assert resp.status_code in (401, 403) + + +@pytest.mark.asyncio +async def test_bridges_non_admin_session_gets_403(client, app, monkeypatch): + def raise_assert(*args, **kwargs): + raise AssertionError("lookup must not be called for non-admin") + + monkeypatch.setattr(app.state.auth, "session_user", lambda token, user_agent=None: {"is_admin": False}) + monkeypatch.setattr("tinyagentos.routes.orb.browse_lan_orbs", raise_assert) + monkeypatch.setattr("tinyagentos.routes.orb.probe_agent_buddy", raise_assert) + monkeypatch.setattr("tinyagentos.routes.orb.evaluate_all", raise_assert) + resp = await client.get("/api/orb/bridges") + assert resp.status_code == 403 + + +@pytest.mark.asyncio +async def test_bridges_admin_one_orb_mimiclaw_target(client, app, monkeypatch): + fake_lan = [ + { + "name": "orb-1", + "addresses": ["192.168.1.20"], + "port": 80, + "board": "x", + "app": "mimiclaw", + "version": "1", + } + ] + monkeypatch.setattr("tinyagentos.routes.orb.browse_lan_orbs", lambda: fake_lan) + monkeypatch.setattr("tinyagentos.routes.orb.probe_agent_buddy", lambda: []) + resp = await client.get("/api/orb/bridges") + assert resp.status_code == 200 + body = resp.json() + assert body["errors"] == [] + bridges = body["bridges"] + assert any(b.get("bridge_id") == "mimiclaw" for b in bridges) + mimiclaw = next(b for b in bridges if b.get("bridge_id") == "mimiclaw") + targets = mimiclaw["targets"] + assert any(t.get("address") == "192.168.1.20" for t in targets) + + +@pytest.mark.asyncio +async def test_bridges_admin_probe_transport_unavailable(client, app, monkeypatch): + fake_lan = [{"name": "orb-1", "addresses": ["192.168.1.10"], "port": 5353}] + monkeypatch.setattr("tinyagentos.routes.orb.browse_lan_orbs", lambda: fake_lan) + monkeypatch.setattr( + "tinyagentos.routes.orb.probe_agent_buddy", + lambda: (_ for _ in ()).throw(TransportUnavailable("x")), + ) + resp = await client.get("/api/orb/bridges") + assert resp.status_code == 200 + body = resp.json() + assert body["errors"] == [{"source": "agent_buddy", "error": "unavailable"}] + assert "bridges" in body + + +@pytest.mark.asyncio +async def test_bridges_admin_evaluate_all_passthrough(client, app, monkeypatch): + monkeypatch.setattr("tinyagentos.routes.orb.browse_lan_orbs", lambda: []) + monkeypatch.setattr("tinyagentos.routes.orb.probe_agent_buddy", lambda: []) + monkeypatch.setattr( + "tinyagentos.routes.orb.evaluate_all", + lambda lan_orbs, agent_buddy: [{"bridge_id": "x"}], + ) + resp = await client.get("/api/orb/bridges") + assert resp.status_code == 200 + body = resp.json() + assert body["bridges"] == [{"bridge_id": "x"}] diff --git a/tinyagentos/routes/orb.py b/tinyagentos/routes/orb.py index fa23a08f6..6ae08d3a4 100644 --- a/tinyagentos/routes/orb.py +++ b/tinyagentos/routes/orb.py @@ -8,6 +8,8 @@ Returns plain ``port``, ``vid``, ``pid``, ``serial_number``, ``description`` fields, no signing key, no mesh_preauth. + +Routes: GET /api/orb/status, GET /api/orb/lan, GET /api/orb/bridges. """ from __future__ import annotations @@ -16,6 +18,7 @@ from fastapi import APIRouter, Request from fastapi.responses import JSONResponse +from tinyagentos.orb.bridges import evaluate_all from tinyagentos.orb.discovery import browse_lan_orbs, list_usb_orbs, probe_agent_buddy from tinyagentos.orb.frames import TransportUnavailable from tinyagentos.routes.auth import _require_admin @@ -40,15 +43,26 @@ async def orb_lan(request: Request): ok, err = _require_admin(request) if not ok: return err + lan_orbs, agent_buddy, errors = await _gather_lan() + return {"lan_orbs": lan_orbs, "agent_buddy": agent_buddy, "errors": errors} + + +async def _gather_lan() -> tuple[list, list, list]: + """Run mDNS Orb browse and Agent Buddy probe concurrently. + + Returns ``(lan_orbs, agent_buddy, errors)``. TransportUnavailable and + OSError degrade to an empty list plus an ``{"source", "error"}`` entry; + any other exception is re-raised. + """ results = await asyncio.gather( asyncio.to_thread(browse_lan_orbs), asyncio.to_thread(probe_agent_buddy), return_exceptions=True, ) lan_res, buddy_res = results[0], results[1] - lan_orbs = [] - agent_buddy = [] - errors = [] + lan_orbs: list = [] + agent_buddy: list = [] + errors: list = [] if isinstance(lan_res, Exception): if isinstance(lan_res, (TransportUnavailable, OSError)): lan_orbs = [] @@ -65,4 +79,13 @@ async def orb_lan(request: Request): raise buddy_res else: agent_buddy = buddy_res - return {"lan_orbs": lan_orbs, "agent_buddy": agent_buddy, "errors": errors} + return lan_orbs, agent_buddy, errors + + +@router.get("/api/orb/bridges") +async def orb_bridges(request: Request): + ok, err = _require_admin(request) + if not ok: + return err + lan_orbs, agent_buddy, errors = await _gather_lan() + return {"bridges": evaluate_all(lan_orbs, agent_buddy), "errors": errors}