Add admin-only companion pairing

Split 3/4 of the companion bridge (#863, #871 landed 1/4 and 2/4). Adds admin-only
device pairing to the companion router.

- GET  /api/companion/pair  -- renders a form; never mints (a GET must not mint a
  credential: SameSite=Lax session cookies ride top-level GET navigations, so
  GET-minting would be CSRF-triggerable via a link/<img>)
- POST /api/companion/pair  -- mints a one-time chat-scoped token. Admin-cookie
  only; CSRF-safe because a SameSite=Lax cookie is not sent on a cross-site POST,
  the same protection POST /api/tokens relies on. ?format=json returns the
  pairing payload for an in-app screen.

Minting invalidates the auth middleware's token cache so the code works on the
next request with no restart. companion/pairing.py holds the mint/LAN/QR helpers;
the token is shown once and stored only as a bcrypt hash + prefix
(mirrors routes/api_token_routes.py).

Tests (tests/test_companion_pairing.py):
- a bearer/'api' caller and a non-admin user are rejected by require_admin (403);
  an admin passes
- the token is returned once and persisted only as a hash
- minting invalidates the cache (works without restart)
- minting is exposed on POST, never GET (CSRF)
This commit is contained in:
Mahdi Salmanzade
2026-06-02 07:43:50 +04:00
committed by GitHub
parent 19a4f823a4
commit 05fb48e9d5
5 changed files with 424 additions and 27 deletions
+24 -16
View File
@@ -1,20 +1,28 @@
# Companion bridge (read-only)
# Companion bridge
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`).
A thin, additive layer so a LAN client (e.g. a phone) can discover what an
Odysseus server offers and pair to it, without duplicating any LLM logic.
| 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 |
| Method | Path | Auth | Purpose |
|---|---|---|---|
| GET | `/api/companion/ping` | session or token | cheap, auth-validated health check |
| GET | `/api/companion/info` | session or token | server identity + capability flags |
| GET | `/api/companion/models` | session or token | the **caller's own** model endpoints |
| GET | `/api/companion/pair` | **admin cookie** | pairing page (a form; never mints) |
| POST | `/api/companion/pair` | **admin cookie** | mint a one-time pairing token (`?format=json` for an in-app screen) |
`/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`.
`/models` scopes to the caller's real owner plus legacy null-owner shared rows
(same rule as `owner_filter`) and never returns API-key material.
This module is intentionally read-only. Pairing/token-minting, token-owner
session attribution, and any mutation endpoints are proposed in separate PRs.
## Pairing CSRF posture
Minting happens **only on POST**. The session cookie is `SameSite=Lax`
(`routes/auth_routes.py`), so a browser will not send it on a cross-site POST —
the same protection `POST /api/tokens` relies on. A `GET` would be unsafe (Lax
cookies ride top-level GET navigations), so `GET /pair` only renders a form.
Minting invalidates the auth middleware's token cache, so a freshly minted token
works on the next request without a restart.
The pairing/scoping rules live in small, tested units (`token_owner`,
`owner_can_see`, `mint_pairing_token`, `pairing.*`) — see
`tests/test_companion_readonly.py` and `tests/test_companion_pairing.py`.