diff --git a/companion/README.md b/companion/README.md
index 1d389b1..8f22ff2 100644
--- a/companion/README.md
+++ b/companion/README.md
@@ -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`.
diff --git a/companion/__init__.py b/companion/__init__.py
index 2a39e84..58a841a 100644
--- a/companion/__init__.py
+++ b/companion/__init__.py
@@ -1,8 +1,9 @@
-"""Odysseus companion bridge — additive, read-only LAN endpoints.
+"""Odysseus companion bridge — additive 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.
+Read endpoints (/api/companion/ping, /info, owner-scoped /models) so a LAN
+client can discover what a server offers, plus admin-only pairing
+(/api/companion/pair) that mints a one-time chat-scoped token on POST. No new LLM
+logic; auth is enforced by the existing AuthMiddleware. See companion/README.md.
"""
from companion.routes import setup_companion_routes
diff --git a/companion/pairing.py b/companion/pairing.py
new file mode 100644
index 0000000..66c63bc
--- /dev/null
+++ b/companion/pairing.py
@@ -0,0 +1,121 @@
+"""Shared pairing helpers for the companion bridge.
+
+Token minting + LAN discovery + QR rendering, kept here as small, importable
+units so the route layer stays thin and the logic is directly testable.
+"""
+
+from __future__ import annotations
+
+import json
+import os
+import secrets
+import socket
+import uuid
+
+import bcrypt
+
+PAIRING_VERSION = 1
+COMPANION_SCOPE = "chat"
+
+
+def default_port() -> int:
+ """Best guess at the port the server is reachable on. Callers that know the
+ real request port should pass it explicitly."""
+ try:
+ return int(os.environ.get("APP_PORT", "7000"))
+ except ValueError:
+ return 7000
+
+
+def lan_ip_candidates() -> list[str]:
+ """Likely LAN IPv4 addresses for this host, best candidate first.
+
+ The UDP-connect trick reveals the egress interface the OS would use to reach
+ the default gateway -- i.e. the address a phone on the same Wi-Fi should
+ target. No packets are actually sent. Loopback is dropped.
+ """
+ candidates: list[str] = []
+
+ def _add(ip):
+ if ip and ip not in candidates and not ip.startswith("127."):
+ candidates.append(ip)
+
+ s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ try:
+ s.connect(("8.8.8.8", 80))
+ _add(s.getsockname()[0])
+ except OSError:
+ pass
+ finally:
+ s.close()
+
+ try:
+ for info in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET):
+ _add(info[4][0])
+ except OSError:
+ pass
+
+ return candidates
+
+
+def find_admin_user() -> str | None:
+ """Resolve an admin username from data/auth.json (schema uses is_admin),
+ falling back to the first user."""
+ auth_path = os.path.join("data", "auth.json")
+ try:
+ with open(auth_path, "r", encoding="utf-8") as f:
+ users = (json.load(f) or {}).get("users", {})
+ except (OSError, json.JSONDecodeError):
+ return None
+ for uname, udata in users.items():
+ if udata.get("is_admin") is True:
+ return uname
+ return next(iter(users), None)
+
+
+def mint_token(owner: str, name: str = "companion") -> tuple[str, str]:
+ """Create a chat-scoped API token row and return (token_id, raw_token).
+
+ The raw token is returned ONCE -- only its bcrypt hash + an 8-char prefix
+ are persisted. Mirrors routes/api_token_routes.py so cookie- and
+ companion-minted tokens are indistinguishable to the auth middleware.
+ """
+ from core.database import get_db_session, ApiToken
+
+ raw_token = "ody_" + secrets.token_urlsafe(32)
+ token_hash = bcrypt.hashpw(raw_token.encode(), bcrypt.gensalt()).decode()
+ token_id = str(uuid.uuid4())[:8]
+
+ with get_db_session() as db:
+ db.add(ApiToken(
+ id=token_id,
+ owner=owner,
+ name=name,
+ token_hash=token_hash,
+ token_prefix=raw_token[:8],
+ scopes=COMPANION_SCOPE,
+ is_active=True,
+ ))
+ return token_id, raw_token
+
+
+def pairing_payload(host: str, port: int, token: str) -> dict:
+ """The exact JSON a client scans / accepts. Keep keys stable."""
+ return {"v": PAIRING_VERSION, "host": host, "port": port, "token": token}
+
+
+def pairing_qr_png_data_uri(payload: dict) -> str | None:
+ """Render the pairing payload as a QR `data:` URI for an . Returns None
+ if the optional qrcode dep is unavailable."""
+ try:
+ import base64
+ import io
+
+ import qrcode
+
+ img = qrcode.make(json.dumps(payload, separators=(",", ":")))
+ buf = io.BytesIO()
+ img.save(buf, format="PNG")
+ return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode()
+ except Exception:
+ return None
diff --git a/companion/routes.py b/companion/routes.py
index 34427d0..e0a24a1 100644
--- a/companion/routes.py
+++ b/companion/routes.py
@@ -1,19 +1,30 @@
-"""Companion bridge — read-only endpoints (/api/companion/*).
+"""Companion bridge — /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.
+offers and pair to it, without duplicating any LLM logic.
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.
+API token. The read endpoints (ping/info/models) accept either; the pairing
+endpoints are admin-cookie only.
+
+Pairing CSRF posture: minting happens ONLY on POST. The session cookie is
+SameSite=Lax (routes/auth_routes.py), which a browser does not send on a
+cross-site POST, so an admin's cookie can't be used by a malicious page to mint
+a token -- the same protection the existing POST /api/tokens relies on. Minting
+on a GET would be unsafe (Lax cookies ride top-level GET navigations), so GET
+/pair only renders a form.
"""
+import html
+
from fastapi import APIRouter, Request
+from fastapi.responses import HTMLResponse
from src.auth_helpers import get_current_user
+from companion import pairing as _pairing
+
def token_owner(request: Request) -> str | None:
"""The real owner to attribute a request to, for read-scoping.
@@ -40,6 +51,20 @@ def owner_can_see(row_owner, owner) -> bool:
return row_owner is None or row_owner == owner
+def mint_pairing_token(owner: str, invalidate=None) -> tuple[str, str]:
+ """Mint a pairing token AND invalidate the auth middleware's in-memory token
+ cache, so the new token is accepted on the very next request without a server
+ restart. Returns (token_id, raw_token); the raw token is shown once.
+
+ `invalidate` is the app's request.app.state.invalidate_token_cache callable
+ (passed in so this stays a pure, testable unit).
+ """
+ token_id, raw_token = _pairing.mint_token(owner)
+ if callable(invalidate):
+ invalidate()
+ return token_id, raw_token
+
+
def setup_companion_routes() -> APIRouter:
router = APIRouter(prefix="/api/companion", tags=["companion"])
@@ -93,8 +118,6 @@ def setup_companion_routes() -> APIRouter:
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:
@@ -121,4 +144,92 @@ def setup_companion_routes() -> APIRouter:
db.close()
return {"endpoints": out}
+ @router.get("/pair")
+ def pair_page(request: Request):
+ """Admin-only pairing page. Renders a form that POSTs to mint a code.
+
+ A GET never mints a credential: SameSite=Lax session cookies ride
+ top-level GET navigations, so minting on GET would be triggerable by a
+ link or
(CSRF). The actual mint is the POST handler below.
+ """
+ require_admin(request)
+ page = """
+
Generate a one-time pairing code (a chat-scoped API token) for a LAN client.
+ +Admin only. Each code mints a new token, shown once. Manage or revoke under Settings → API tokens.
+QR rendering unavailable -- enter the details manually.
" + ) + page = f""" + +{html.escape(host)}{html.escape(str(port))}{html.escape(raw_token)}{html.escape(payload_json)}Shown once. This grants chat access to your Odysseus; revoke it
+ in Settings → API tokens (id {html.escape(token_id)}). The
+ device must be on the same network, and the server must bind to your LAN.