From 50a8b346f5f1737ea73ed12eb07d3a088ffaea2b Mon Sep 17 00:00:00 2001 From: jaylfc Date: Sun, 25 Oct 2026 10:54:18 +0000 Subject: [PATCH 1/2] tsk-7tqplf: add admin-gated GET /api/orb/lan Add GET /api/orb/lan that requires admin session, runs browse_lan_orbs and probe_agent_buddy concurrently via asyncio.to_thread, and returns lan_orbs, agent_buddy, and errors. On TransportUnavailable/OSError for a source, that source's list is [] and errors gets a structured entry with error unavailable; other exceptions are re-raised. Docs-Reviewed: route addition is internal API surface consistent with existing /api/orb/status; no user-facing guide change needed. --- changelog.d/tsk-7tqplf-orb-lan-route.md | 3 + tests/test_routes_orb.py | 99 ++++++++++++++++++++++++- tinyagentos/routes/orb.py | 37 ++++++++- 3 files changed, 136 insertions(+), 3 deletions(-) create mode 100644 changelog.d/tsk-7tqplf-orb-lan-route.md diff --git a/changelog.d/tsk-7tqplf-orb-lan-route.md b/changelog.d/tsk-7tqplf-orb-lan-route.md new file mode 100644 index 000000000..bb9b88caf --- /dev/null +++ b/changelog.d/tsk-7tqplf-orb-lan-route.md @@ -0,0 +1,3 @@ +### Added + +- Add admin-gated GET /api/orb/lan for concurrent mDNS Orb browse and Agent Buddy UDP probe (tinyagentos/routes/orb.py). diff --git a/tests/test_routes_orb.py b/tests/test_routes_orb.py index 65af5f332..96c13e2d9 100644 --- a/tests/test_routes_orb.py +++ b/tests/test_routes_orb.py @@ -94,4 +94,101 @@ async def test_status_admin_transport_unavailable(client, app, monkeypatch): resp = await client.get("/api/orb/status") assert resp.status_code == 503 body = resp.json() - assert body == {"error": "orb_transport_unavailable"} \ No newline at end of file + assert body == {"error": "orb_transport_unavailable"} + +# --------------------------------------------------------------------------- +# GET /api/orb/lan (LAN discovery: mDNS + Agent Buddy) +# --------------------------------------------------------------------------- + +import asyncio +import threading + +from tinyagentos.orb.frames import TransportUnavailable + + +@pytest.mark.asyncio +async def test_lan_unauthed_requires_admin(unauthed_client): + resp = await unauthed_client.get("/api/orb/lan") + assert resp.status_code in (401, 403) + + +@pytest.mark.asyncio +async def test_lan_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) + resp = await client.get("/api/orb/lan") + assert resp.status_code == 403 + + +@pytest.mark.asyncio +async def test_lan_admin_both_succeed(client, app, monkeypatch): + fake_lan = [{"name": "orb-1", "addresses": ["192.168.1.10"], "port": 5353}] + fake_buddy = [{"device_id": "abcd1234abcd1234", "hostname": "buddy1", "address": "192.168.1.20", "port": 4666, "boot": 1}] + monkeypatch.setattr("tinyagentos.routes.orb.browse_lan_orbs", lambda: fake_lan) + monkeypatch.setattr("tinyagentos.routes.orb.probe_agent_buddy", lambda: fake_buddy) + resp = await client.get("/api/orb/lan") + assert resp.status_code == 200 + body = resp.json() + assert body["lan_orbs"] == fake_lan + assert body["agent_buddy"] == fake_buddy + assert body["errors"] == [] + + +@pytest.mark.asyncio +async def test_lan_admin_probe_agent_buddy_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/lan") + assert resp.status_code == 200 + body = resp.json() + assert body["lan_orbs"] == fake_lan + assert body["agent_buddy"] == [] + assert body["errors"] == [{"source": "agent_buddy", "error": "unavailable"}] + + +@pytest.mark.asyncio +async def test_lan_admin_browse_lan_orbs_oserror(client, app, monkeypatch): + fake_buddy = [{"device_id": "abcd1234abcd1234", "hostname": "buddy1", "address": "192.168.1.20", "port": 4666, "boot": 1}] + monkeypatch.setattr("tinyagentos.routes.orb.browse_lan_orbs", lambda: (_ for _ in ()).throw(OSError("x"))) + monkeypatch.setattr("tinyagentos.routes.orb.probe_agent_buddy", lambda: fake_buddy) + resp = await client.get("/api/orb/lan") + assert resp.status_code == 200 + body = resp.json() + assert body["lan_orbs"] == [] + assert body["agent_buddy"] == fake_buddy + assert body["errors"] == [{"source": "mdns", "error": "unavailable"}] + + +@pytest.mark.asyncio +async def test_lan_admin_browse_lan_orbs_runtime_error_raised(client, app, monkeypatch): + fake_buddy = [] + monkeypatch.setattr("tinyagentos.routes.orb.browse_lan_orbs", lambda: (_ for _ in ()).throw(RuntimeError("bad"))) + monkeypatch.setattr("tinyagentos.routes.orb.probe_agent_buddy", lambda: fake_buddy) + with pytest.raises(RuntimeError): + await client.get("/api/orb/lan") + + +@pytest.mark.asyncio +async def test_lan_concurrent_execution_no_sleep(client, app, monkeypatch): + barrier = threading.Barrier(2, timeout=2) + + def fake_browse(): + barrier.wait() + return [] + + def fake_probe(): + barrier.wait() + return [] + + monkeypatch.setattr("tinyagentos.routes.orb.browse_lan_orbs", fake_browse) + monkeypatch.setattr("tinyagentos.routes.orb.probe_agent_buddy", fake_probe) + resp = await client.get("/api/orb/lan") + assert resp.status_code == 200 diff --git a/tinyagentos/routes/orb.py b/tinyagentos/routes/orb.py index 24e667cbd..fa23a08f6 100644 --- a/tinyagentos/routes/orb.py +++ b/tinyagentos/routes/orb.py @@ -16,7 +16,7 @@ from fastapi import APIRouter, Request from fastapi.responses import JSONResponse -from tinyagentos.orb.discovery import list_usb_orbs +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 @@ -32,4 +32,37 @@ async def orb_status(request: Request): orbs = await asyncio.to_thread(list_usb_orbs) except TransportUnavailable: return JSONResponse({"error": "orb_transport_unavailable"}, status_code=503) - return {"orbs": orbs} \ No newline at end of file + return {"orbs": orbs} + + +@router.get("/api/orb/lan") +async def orb_lan(request: Request): + ok, err = _require_admin(request) + if not ok: + return err + 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 = [] + if isinstance(lan_res, Exception): + if isinstance(lan_res, (TransportUnavailable, OSError)): + lan_orbs = [] + errors.append({"source": "mdns", "error": "unavailable"}) + else: + raise lan_res + else: + lan_orbs = lan_res + if isinstance(buddy_res, Exception): + if isinstance(buddy_res, (TransportUnavailable, OSError)): + agent_buddy = [] + errors.append({"source": "agent_buddy", "error": "unavailable"}) + else: + raise buddy_res + else: + agent_buddy = buddy_res + return {"lan_orbs": lan_orbs, "agent_buddy": agent_buddy, "errors": errors} From 0865a18b36b03ec5a0b1e1e7941f6c0b1c1b8fc9 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Sun, 25 Oct 2026 10:57:41 +0000 Subject: [PATCH 2/2] test: harness-measured red for tsk-7tqplf RED-FIRST proof measured by the harness (measure_red.sh): the changed tests run against product code at base eec7710a1, then against this branch. RED on the base: ``` E AttributeError: module 'tinyagentos.routes.orb' has no attribute 'browse_lan_orbs' E AttributeError: 'module' object at tinyagentos.routes.orb has no attribute 'browse_lan_orbs' FAILED tests/test_routes_orb.py::test_lan_non_admin_session_gets_403 - Attrib... FAILED tests/test_routes_orb.py::test_lan_admin_both_succeed - AttributeError... FAILED tests/test_routes_orb.py::test_lan_admin_probe_agent_buddy_transport_unavailable FAILED tests/test_routes_orb.py::test_lan_admin_browse_lan_orbs_oserror - Att... FAILED tests/test_routes_orb.py::test_lan_admin_browse_lan_orbs_runtime_error_raised FAILED tests/test_routes_orb.py::test_lan_concurrent_execution_no_sleep - Att... 6 failed, 6 passed in 28.64s ``` GREEN on the fix: ``` 12 passed in 20.10s ```