diff --git a/app.py b/app.py index 838a0de..b31f0d8 100644 --- a/app.py +++ b/app.py @@ -679,6 +679,9 @@ app.include_router(setup_vault_routes()) from routes.contacts_routes import setup_contacts_routes app.include_router(setup_contacts_routes()) +from companion import setup_companion_routes +app.include_router(setup_companion_routes()) + # ========= ROUTES (kept in app.py) ========= def _serve_html_with_nonce(request: Request, file_path: str) -> HTMLResponse: diff --git a/companion/README.md b/companion/README.md new file mode 100644 index 0000000..1d389b1 --- /dev/null +++ b/companion/README.md @@ -0,0 +1,20 @@ +# Companion bridge (read-only) + +A thin, additive layer so a LAN client can discover what an Odysseus server +offers, without duplicating any LLM logic. Reachable with either a logged-in +cookie session or a Bearer `ody_` API token (auth is enforced globally by +`AuthMiddleware`). + +| Method | Path | Purpose | +|---|---|---| +| GET | `/api/companion/ping` | cheap, auth-validated health check | +| GET | `/api/companion/info` | server identity + capability flags | +| GET | `/api/companion/models` | the **caller's own** model endpoints | + +`/models` scopes to the caller's real owner (the token's owner for bearer +callers) plus legacy null-owner shared rows, the same rule as `owner_filter`. It +never returns API-key material. The owner rule lives in two pure, tested helpers +(`token_owner`, `owner_can_see`) — see `tests/test_companion_readonly.py`. + +This module is intentionally read-only. Pairing/token-minting, token-owner +session attribution, and any mutation endpoints are proposed in separate PRs. diff --git a/companion/__init__.py b/companion/__init__.py new file mode 100644 index 0000000..2a39e84 --- /dev/null +++ b/companion/__init__.py @@ -0,0 +1,10 @@ +"""Odysseus companion bridge — additive, read-only LAN endpoints. + +Exposes /api/companion/ping, /info, and an owner-scoped /models so a LAN client +can discover what a server offers. No new LLM logic; auth is enforced by the +existing AuthMiddleware. See companion/README.md. +""" + +from companion.routes import setup_companion_routes + +__all__ = ["setup_companion_routes"] diff --git a/companion/routes.py b/companion/routes.py new file mode 100644 index 0000000..34427d0 --- /dev/null +++ b/companion/routes.py @@ -0,0 +1,124 @@ +"""Companion bridge — read-only endpoints (/api/companion/*). + +A thin, additive layer so a LAN client (e.g. a phone) can discover what a server +offers without duplicating any LLM logic. This module is intentionally +read-only: it exposes a cheap health check, server identity, and the caller's +own model list. Pairing/token-minting and any mutation live in separate changes. + +Auth is enforced globally by AuthMiddleware (app.py), so reaching a handler here +means the caller is authenticated by either a cookie session or a Bearer `ody_` +API token. +""" + +from fastapi import APIRouter, Request + +from src.auth_helpers import get_current_user + + +def token_owner(request: Request) -> str | None: + """The real owner to attribute a request to, for read-scoping. + + Cookie sessions resolve to the logged-in username via get_current_user. + Bearer-token callers come through as the sandboxed pseudo-user "api"; their + real owner is stamped on request.state.api_token_owner by the auth + middleware. Returns None when no owner can be resolved. + """ + if getattr(request.state, "api_token", False): + return getattr(request.state, "api_token_owner", None) + return get_current_user(request) + + +def owner_can_see(row_owner, owner) -> bool: + """Owner-scope rule for read endpoints. + + A caller sees a row when it is their own, or when it is a legacy null-owner + ("shared") row. A caller must NEVER see another owner's row. Mirrors the + `owner_filter` rule used elsewhere, expressed as a pure predicate so it can + be tested directly and used as a defensive in-Python check alongside the + SQL filter. + """ + return row_owner is None or row_owner == owner + + +def setup_companion_routes() -> APIRouter: + router = APIRouter(prefix="/api/companion", tags=["companion"]) + + @router.get("/ping") + def ping(request: Request): + """Cheap, auth-validated health check. A 200 with ok=true confirms the + host/port and credential are valid; middleware returns 401 otherwise.""" + from core.constants import APP_VERSION + return { + "ok": True, + "name": "odysseus", + "version": APP_VERSION, + "auth": "token" if getattr(request.state, "api_token", False) else "session", + } + + @router.get("/info") + def info(request: Request): + """Server identity + coarse capability flags. `owner` is the caller's own + identity (the token's owner for bearer callers).""" + from core.constants import APP_VERSION + return { + "name": "odysseus", + "version": APP_VERSION, + "owner": token_owner(request), + "capabilities": {"chat": True, "streaming": True}, + } + + @router.get("/models") + def models(request: Request): + """LLM model endpoints the CALLER can use. + + The stock /api/models route scopes to get_current_user, which for a + bearer token is the sandboxed pseudo-user "api" (owns nothing). Here we + scope to the token's real owner instead, plus legacy null-owner shared + rows -- the same rule as owner_filter. Read-only; never returns api_key + material. + """ + import json as _json + + from core.database import SessionLocal, ModelEndpoint + from src.endpoint_resolver import build_chat_url + + owner = token_owner(request) + out = [] + db = SessionLocal() + try: + q = db.query(ModelEndpoint).filter( + ModelEndpoint.is_enabled == True, # noqa: E712 + (ModelEndpoint.model_type == "llm") | (ModelEndpoint.model_type == None), # noqa: E711 + ) + if owner: + q = q.filter((ModelEndpoint.owner == owner) | (ModelEndpoint.owner == None)) # noqa: E711 + for ep in q.all(): + # Defence in depth: never emit a row the owner rule rejects, even + # if the SQL filter above were ever loosened. + if not owner_can_see(ep.owner, owner): + continue + try: + model_ids = _json.loads(ep.cached_models) if ep.cached_models else [] + except (ValueError, TypeError): + model_ids = [] + try: + hidden = set(_json.loads(ep.hidden_models)) if ep.hidden_models else set() + except (ValueError, TypeError): + hidden = set() + model_ids = [m for m in model_ids if m not in hidden] + try: + chat_url = build_chat_url(ep.base_url) + except Exception: + chat_url = ep.base_url + out.append({ + "endpoint_id": ep.id, + "name": ep.name, + "endpoint_url": chat_url, + "models": model_ids, + "supports_tools": ep.supports_tools, + }) + finally: + db.close() + return {"endpoints": out} + + return router diff --git a/tests/test_companion_readonly.py b/tests/test_companion_readonly.py new file mode 100644 index 0000000..2ea1edb --- /dev/null +++ b/tests/test_companion_readonly.py @@ -0,0 +1,78 @@ +"""Owner-scope tests for the read-only companion bridge. + +Mirrors the direct-helper style of tests/test_null_owner_gates.py: exercise the +small pure helpers against mock request state and owner values, so the scoping +rule can't silently regress. A bearer token for owner A must never see owner B's +rows, and legacy null-owner rows must not widen a token's access. +""" + +import os +import sys +import types +from types import SimpleNamespace +from unittest.mock import MagicMock + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +# core.database instantiates SQLAlchemy declarative classes at import time, which +# blows up under conftest's sqlalchemy MagicMock stubs. companion.routes only +# imports it lazily inside the /models handler, but stub it defensively so the +# import is robust regardless of collection order. +if "core.database" not in sys.modules: + _db = types.ModuleType("core.database") + _db.SessionLocal = MagicMock() + _db.ModelEndpoint = MagicMock() + sys.modules["core.database"] = _db + +from companion.routes import token_owner, owner_can_see + + +def _request(**state): + return SimpleNamespace(state=SimpleNamespace(**state)) + + +# --- token_owner: who a request is attributed to --------------------------- + +def test_token_owner_bearer_resolves_to_token_owner(): + # A paired bearer caller runs as the "api" pseudo-user, but must attribute + # to the token's real owner. + req = _request(api_token=True, api_token_owner="alice", current_user="api") + assert token_owner(req) == "alice" + + +def test_token_owner_cookie_uses_logged_in_user(): + req = _request(api_token=False, current_user="alice") + assert token_owner(req) == "alice" + + +def test_token_owner_none_when_unresolved(): + req = _request(api_token=True, api_token_owner=None, current_user="api") + assert token_owner(req) is None + + +# --- owner_can_see: the read-scope rule ------------------------------------ + +def test_owner_sees_their_own_rows(): + assert owner_can_see("alice", "alice") is True + + +def test_null_owner_shared_rows_are_visible(): + # Legacy shared rows (owner is None) are visible to everyone by design... + assert owner_can_see(None, "alice") is True + + +def test_null_owner_does_not_widen_access_to_others_rows(): + # ...but a null-owner row must not be a backdoor to another OWNER's rows. + assert owner_can_see("bob", "alice") is False + + +def test_cross_owner_is_blocked(): + assert owner_can_see("bob", "alice") is False + assert owner_can_see("alice", "bob") is False + + +def test_unauthenticated_owner_sees_only_shared_rows(): + # owner=None (no resolved caller): only null-owner shared rows are visible, + # never any owned row. + assert owner_can_see(None, None) is True + assert owner_can_see("alice", None) is False