feat: provision private Hermes assistants for authenticated Hope accounts

This commit is contained in:
snowspeeder committed 2026-10-01 13:30:18 -04:00
1 parent da65da68b3
commit 2aedea84b5
33 files changed
+3063 -1743

No files matched your search

+58 -100
View File
@@ -1,15 +1,5 @@
#!/usr/bin/env python3
"""Tiny token endpoint for the voice UI.
Serves POST /token -> { "token": "<signed JWT>" }
Signs a LiveKit access token with HS256 using the API key/secret from env.
The token carries a roomConfig claim requesting the voice-assistant agent, so
LiveKit dispatches the agent when the participant joins. Without this, the
auto-created room would get no agent at all.
Runs as a separate supervisord process; nginx proxies /token to it.
"""
"""Authenticated room bootstrap. All identity/routing is resolved server-side."""
import base64
import hashlib
import hmac
@@ -18,22 +8,17 @@ import os
import time
import uuid
from http.server import BaseHTTPRequestHandler, HTTPServer
import httpx
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
API_KEY = os.environ.get("LIVEKIT_API_KEY", "devkey")
API_SECRET = os.environ.get("LIVEKIT_API_SECRET", "devsecret")
AGENT_NAME = os.environ.get("VOICE_AGENT_NAME", "voice-assistant")
USERS_FILE = os.environ.get("VOICE_USERS_FILE", "/etc/hope/users.json")
DEFAULT_USER = os.environ.get("VOICE_DEFAULT_USER", "shane")
def load_default_user() -> tuple[str, dict]:
with open(USERS_FILE, encoding="utf-8") as users_file:
registry = json.load(users_file)
profile = registry.get("users", {}).get(DEFAULT_USER)
if not isinstance(profile, dict):
raise ValueError(f"unknown default voice user: {DEFAULT_USER}")
return DEFAULT_USER, profile
API_KEY = os.getenv("LIVEKIT_API_KEY", "")
API_SECRET = os.getenv("LIVEKIT_API_SECRET", "")
AGENT_NAME = os.getenv("VOICE_AGENT_NAME", "voice-assistant")
CONTROL_URL = os.getenv("CONTROL_URL", "http://127.0.0.1:8092").rstrip("/")
CONTROL_SECRET = os.getenv("CONTROL_SERVICE_SECRET", "")
COOKIE_NAME = "hope_session"
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
def b64url(data: bytes) -> str:
@@ -41,82 +26,55 @@ def b64url(data: bytes) -> str:
def make_token(room_name: str, identity: str) -> str:
if not API_KEY or not API_SECRET:
raise RuntimeError("LiveKit credentials are not configured")
now = int(time.time())
header = {"alg": "HS256", "typ": "JWT"}
payload = {
"iss": API_KEY,
"sub": identity,
"nbf": now - 5,
"exp": now + 3600,
"jti": str(uuid.uuid4()),
"identity": identity,
"video": {
"roomJoin": True,
"room": room_name,
"canPublish": True,
"canSubscribe": True,
"canPublishData": True,
},
"roomConfig": {
"agents": [{"agentName": AGENT_NAME, "jobType": "JT_ROOM"}],
},
}
h = b64url(json.dumps(header).encode())
p = b64url(json.dumps(payload).encode())
signing_input = f"{h}.{p}".encode()
sig = hmac.new(API_SECRET.encode(), signing_input, hashlib.sha256).digest()
return f"{h}.{p}.{b64url(sig)}"
payload = {"iss": API_KEY, "sub": identity, "nbf": now - 5, "exp": now + 300,
"jti": str(uuid.uuid4()), "video": {"roomJoin": True, "room": room_name,
"canPublish": True, "canSubscribe": True, "canPublishData": True,
"canPublishSources": ["microphone"]},
"roomConfig": {"agents": [{"agentName": AGENT_NAME, "jobType": "JT_ROOM"}]}}
head = b64url(json.dumps({"alg": "HS256", "typ": "JWT"}).encode())
body = b64url(json.dumps(payload).encode())
sig = hmac.new(API_SECRET.encode(), f"{head}.{body}".encode(), hashlib.sha256).digest()
return f"{head}.{body}.{b64url(sig)}"
class Handler(BaseHTTPRequestHandler):
def do_POST(self):
if self.path != "/token":
self.send_response(404)
self.end_headers()
return
length = int(self.headers.get("Content-Length", 0))
# Consume the body for protocol compatibility, but never trust a
# browser-supplied room or identity. This LAN bootstrap deployment is
# pinned to VOICE_DEFAULT_USER until the planned login flow is added.
self.rfile.read(length)
try:
user_id, profile = load_default_user()
except (OSError, ValueError, json.JSONDecodeError) as exc:
resp = json.dumps({"error": str(exc)}).encode()
self.send_response(503)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(resp)))
self.end_headers()
self.wfile.write(resp)
return
room_name = f"voice-{user_id}-{uuid.uuid4().hex[:8]}"
identity = profile.get("livekit_identity") or f"user:{user_id}"
token = make_token(room_name, identity)
resp = json.dumps(
{
"token": token,
# Returned for diagnostics and reconnects. Music deliberately
# uses its own stable scope in the separate Hope Music service.
"room": room_name,
"user": {
"id": user_id,
"display_name": profile.get("display_name") or user_id,
},
}
).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(resp)))
self.end_headers()
self.wfile.write(resp)
def log_message(self, format, *args):
pass # silence request logging
@app.post("/token")
async def token(request: Request):
headers = {"Cache-Control": "no-store"}
if not CONTROL_SECRET or not API_KEY or not API_SECRET:
return JSONResponse({"error": "Service configuration unavailable"}, 503, headers=headers)
auth = request.headers.get("authorization", "")
bearer = auth[7:] if auth.startswith("Bearer ") else ""
credential = bearer or request.cookies.get(COOKIE_NAME, "")
if not credential or len(credential) > 512:
return JSONResponse({"error": "Sign in required"}, 401, headers=headers)
if not bearer:
origin = request.headers.get("origin", "")
allowed = set(filter(None, os.getenv("CONTROL_ALLOWED_ORIGINS", "").split(",")))
if origin not in allowed:
return JSONResponse({"error": "Invalid request origin"}, 403, headers=headers)
try:
async with httpx.AsyncClient(timeout=10) as client:
control_headers = {"X-Control-Secret": CONTROL_SECRET}
response = await client.post(f"{CONTROL_URL}/internal/resolve-session", headers=control_headers,
json={"token": credential})
if response.status_code != 200:
status = response.status_code if response.status_code in (401,403,503) else 503
return JSONResponse({"error": "Sign in required" if status==401 else "Your private agent is not ready"}, status, headers=headers)
account = response.json()
user = account["user"]
room_name = f"voice-{user['id']}-{uuid.uuid4().hex}"
grant = await client.post(f"{CONTROL_URL}/internal/rooms", headers=control_headers,
json={"user_id": user["id"], "room_name": room_name, "ttl": 86400,
"session_token": credential})
grant.raise_for_status()
return JSONResponse({"token": make_token(room_name, f"user:{user['id']}"), "room": room_name,
"user": {"id": user["id"], "display_name": user["display_name"]}}, headers=headers)
except (httpx.HTTPError, ValueError, KeyError):
return JSONResponse({"error": "Account service unavailable"}, 503, headers=headers)
if __name__ == "__main__":
server = HTTPServer(("127.0.0.1", 8091), Handler)
print("token endpoint listening on 127.0.0.1:8091")
server.serve_forever()
import uvicorn
uvicorn.run(app, host="127.0.0.1", port=8091, access_log=False)