Add read-only companion endpoints (ping/info/owner-scoped models) (#863)

First, smallest cut of a LAN companion bridge (split out of #855 per review):
a thin, additive, read-only layer so a LAN client can discover what a server
offers. No new LLM logic; auth is enforced by the existing AuthMiddleware.

- 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, mirroring owner_filter, and never returns
api_key material. The owner rule lives in two pure helpers (token_owner,
owner_can_see) with direct tests proving a token for owner A cannot see owner B's
rows and that null-owner rows don't widen access.
This commit is contained in:
Mahdi Salmanzade
2026-06-02 06:20:53 +04:00
committed by GitHub
parent 4a84a895a0
commit 000bd6d1ab
5 changed files with 235 additions and 0 deletions

3
app.py
View File

@@ -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:

20
companion/README.md Normal file
View File

@@ -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.

10
companion/__init__.py Normal file
View File

@@ -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"]

124
companion/routes.py Normal file
View File

@@ -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

View File

@@ -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