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 6f9a498ce..9691db528 100644 --- a/tests/test_routes_orb.py +++ b/tests/test_routes_orb.py @@ -460,3 +460,81 @@ async def test_catalog_config_round_trip(client, app, monkeypatch, tmp_data_dir) # to_dict should NOT include orb when it's the default d2 = cfg2.to_dict() assert "orb" not in d2 + + +# --------------------------------------------------------------------------- +# 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 975f97381..4b952491d 100644 --- a/tinyagentos/routes/orb.py +++ b/tinyagentos/routes/orb.py @@ -9,7 +9,8 @@ Returns plain ``port``, ``vid``, ``pid``, ``serial_number``, ``description`` fields, no signing key, no mesh_preauth. -GET /api/orb/catalog returns the signed Orb catalog (spec s10). +Routes: GET /api/orb/status, GET /api/orb/lan, GET /api/orb/bridges, +GET /api/orb/catalog (the signed Orb catalog, spec s10). """ from __future__ import annotations @@ -21,6 +22,7 @@ from fastapi import APIRouter, Request from fastapi.responses import JSONResponse +from tinyagentos.orb.bridges import evaluate_all from tinyagentos.orb.catalog_fetch import fetch_catalog, load_cached_catalog from tinyagentos.orb.catalog_sig import ( CatalogFetchError, @@ -55,15 +57,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 = [] @@ -80,7 +93,16 @@ 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} @router.get("/api/orb/catalog")