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

+109
View File
@@ -0,0 +1,109 @@
# Account control plane
Run `python -m control` with FastAPI and uvicorn installed. Defaults to
`127.0.0.1:8092`; configure `CONTROL_HOST` / `CONTROL_PORT` explicitly if needed.
Run one control process against a database (lifecycle scheduling is process-local).
No Docker logic lives in the account API: production imports
`control.provisioner.Provisioner()` (locally aliased HermesProvisioner) lazily
on startup.
## Runtime settings
- `CONTROL_DB_PATH`: SQLite path, default `/data/control/accounts.sqlite3`.
Mount persistent private storage. The DB contains hashes and Hermes keys;
protect the directory and its WAL/SHM files, backups, and host access.
- `CONTROL_ADMIN_USERNAME`: initial administrator lowercase slug, default `admin`.
- `CONTROL_ADMIN_PASSWORD`: **required when DB is empty**, 12–256 characters;
no built-in password. Ignored once existing accounts are present. Do not log it.
- `CONTROL_SERVICE_SECRET`: >=32-character secret for trusted internal callers.
Missing/short secret disables all internal access; send as `X-Control-Secret`.
- `CONTROL_ALLOWED_ORIGINS`: exact comma-separated HTTPS browser origins.
**Set this behind nginx**; no forwarded host/proto headers are trusted.
Native login sends `Content-Type: application/json` and no browser Origin.
Browser login requires its actual Origin to match. Cookie mutations require a
matching Origin; bearer-authenticated requests are exempt. Login is throttled
per validated client IP and username (10 failed attempts / 5 minutes; successes
release their reservation). Only a direct loopback peer may supply `X-Real-IP`,
which nginx must overwrite with `$remote_addr`. Do not expose the control port
or preserve untrusted client-supplied identity headers in another proxy.
Opaque sessions last 24 hours, are hashed at rest and revoked by logout,
password reset or deletion. Cookies are Secure, HttpOnly, SameSite=Strict.
## Public API
- `POST /api/auth/login` JSON `{username,password}` →
`{token,user,expires_at}` (expiry Unix seconds) plus `hope_session` cookie.
- `POST /api/auth/logout` → `{ok:true}`; invalid sessions return 401.
- `GET /api/auth/me` → `{user}`.
- Administrator-only `GET /api/admin/users` → `{users:[...]}`.
- Administrator-only `POST /api/admin/users` JSON
`{username,display_name,password,role?:"user"}` → 201 `{user}`.
- Administrator-only `DELETE /api/admin/users/{id}` → 202 `{user}` in
deleting state. Sessions/rooms revoked synchronously before return; container
deletion is background, durable and retriable. Last administrator is protected.
- Administrator-only `POST /api/admin/users/{id}/password` JSON `{password}` →
`{ok:true}`; revokes all sessions and room grants.
- Administrator-only `POST /api/admin/users/{id}/retry` → 202 `{user}`;
retries failed provisioning or removal, preserving intended lifecycle.
- `GET /health` → `{status:"ok"}` (control HTTP liveness, not Hermes readiness).
Public user is exactly `{id,username,display_name,role,status,container_name,error}`.
IDs are stable UUID hex. Usernames match `[a-z][a-z0-9]*(?:-[a-z0-9]+)*`,
1–48 characters. Status is `provisioning`, `ready`, `error` or `deleting`.
Deleting failures remain deleting with a sanitized visible error, never become
login-eligible. Background reconcile runs on startup and every 30 seconds.
## Trusted speech boundary
Every internal route requires `X-Control-Secret`, including room lookups.
- `POST /internal/resolve-session` JSON `{token}` validates the exact cookie or
bearer token; returns `{user,hermes_url,hermes_api_key}` only for ready user.
- `GET /internal/users/{id}` returns the same ready-user metadata.
- `POST /internal/rooms` JSON `{user_id,room_name,session_token,ttl?:3600}`
validates that session belongs to the user and persists a unique server-minted
room grant, returning 201 `{room_name,user_id}`. TTL 1–86400 sec, capped at
original session expiry.
- `GET /internal/rooms/{room_name}` resolves that grant only while unexpired
and user still active/ready. Returns the same metadata. Deletion/reset revoke
grants immediately; speech agents should poll to stop active conversations.
401 means absent/invalid auth; 403 forbidden role/Origin; 404 missing/deleted
user or invalid room; 503 means not yet ready. Metadata is never in public APIs.
Do not proxy internal endpoints publicly. Token service resolves client sessions,
mints an opaque room name, persists its grant before issuing LiveKit credentials,
and never accepts browser-supplied account IDs, room ownership or Hermes URLs.
For colocated FastAPI integration use `Depends(control.current_user)` and
`Depends(control.administrator)`; these return public user dicts and require
this application's `app.state.accounts`/settings. Separate services use the
secret-authenticated internal endpoints instead.
## Provisioner interface
`create_app(settings: Settings, provisioner=...)` supports offline injection.
The adapter receives a **private trusted dict**, including `id`, `username`,
`container_name`, `desired`, and stored `hermes_url` / `hermes_api_key`.
- `async ensure(user) -> dict`: idempotently create/recover owned container;
return `{container_name,hermes_url,hermes_api_key}`. Container name must match
`hope-hermes-<username>-<id[:8]>`; metadata is persisted before readiness check.
- `async ready(user) -> bool`: owned service health, not existence alone.
- `async remove(user) -> None`: verify management/user ownership before any
removal; retain private data by default; failure raises and remains retriable.
- Optional `async aclose()`: close adapter resources during shutdown.
Async adapters offload blocking Docker/HTTP calls themselves; synchronous
adapters are supported using a worker thread. Account SQLite and scrypt work
are offloaded from the ASGI loop. Slow provision/remove run separate tasks;
request handlers do not await container startup. Exception text is not logged or
returned because provider failures may contain credentials.
Offline tests:
```sh
python -m pytest tests/unit/test_accounts.py --confcutdir=tests/unit -q
```
`--confcutdir` avoids this repository's deployment-oriented top-level fixtures.
Use a temporary DB and fake adapter; no Docker or provider calls are made.