Files
ai-agent/docs/multi-user.md
Gabriel Vidal 7ed72bceb4 docs(multi-user): map what guest accounts would change
Exploration of multi-user support: edge (Authelia identity, guest host),
backend (Caller, grants, ownership, per-user state, SSE), the runner
boundary (sandbox options), billing, frontend lockdown, the mail trigger,
plus four findings worth fixing regardless and a suggested phasing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 14:16:21 +02:00

171 lines
11 KiB
Markdown

# Multi-user — what it would take
Status: **exploration** (2026-10-11). Nothing here is built yet; the open
questions are in the ask-form linked from the conversation that wrote this.
Goal: people other than the owner (Dom, Claire, family…) log into the same
PWA and **chat** — spawn and resume conversations — with access limited to the
projects they've been granted. Today the only way in for a third party is a
mail to `claude@gabvdl.xyz` (the mail-trigger allowlist, guests gated by a
`notify-ask` in `.claude/agents/mail-agent.md`).
## Where the app stands
There is **no notion of a user anywhere**, and that is load-bearing:
| layer | today |
|---|---|
| edge | Authelia forward-auth on `ai-agent.lab.gabvdl.xyz`; the `*.lab` rule admits `gabrielvidal` + `claude` only. Authelia already sends `Remote-User/Groups/Name/Email` — nobody reads them. The host is tailnet/LAN-only (public `*.lab` DNS lands on raspy2, which doesn't route it). |
| API gate | `backend/core/auth.py`: *came from Traefik / loopback / docker gateway* ⇒ **admin**. `READONLY_API_KEY` (the desk phone) is the only downgrade. |
| data | every list (conversations, search index, notifications, forms, plans, projects, dashboards) is global; `ui-state.json` and `notif-read.json` are one shared blob; the SSE hub sends every event to every client. |
| runs | `claude -p --permission-mode bypassPermissions` as uid 1000, cwd = whatever the client sent (only checked to exist), full `os.environ` (incl. `SIDECAR_TOKEN`), user + project hooks/skills/CLAUDE.md, `--remote-control`. A run is a shell on the host. |
| billing | `personal` (the owner's Max seat) by default; no per-run budget, turn cap or spend limit anywhere. |
| notifications | every `notify`/`ask`/`form` goes to the owner's phone through HA webhooks; the PWA does no Web Push. |
So "scope a guest to their projects" is two separate problems:
1. **The API** must know who is calling and filter/deny accordingly.
2. **The run** must not be able to step outside the project — because a guest
session on today's runner can `curl 127.0.0.1:8096/api/spawn` (trusted
loopback ⇒ admin), read `~/.claude`, `.env` files and SSH keys, drive the
sidecar with the inherited `SIDECAR_TOKEN`, or inject turns into the owner's
live sessions through the inbox sockets (same uid). Backend filtering alone
is cosmetic until (2) is solved.
## The changes, layer by layer
### 1. Edge — reachability and login
- **Identity**: Authelia users (`services/authelia/config/users.yml`, a
`guests` group) are the cheapest path — the backend reads `Remote-User` /
`Remote-Groups` / `Remote-Email`, **only when the TCP peer is Traefik**
(loopback/gateway callers could forge them). Authelia's notifier is the
filesystem today, so password reset / TOTP enrolment mails go nowhere — it
needs SMTP to the mail service before inviting anyone. A built-in login
(email magic link, passkeys) is the standalone-friendly alternative (GOAL.md
wants the single container to work without Authelia).
- **Reachability**: guests are not on the tailnet. Options: Tailscale node
share (shared-in users don't get the tailnet's CoreDNS), publish
`ai-agent.lab` through the raspy2 passthrough, or a **separate guest host**
(e.g. `chat.lab.gabvdl.xyz`) that the backend treats as guest-only even for
the owner — the admin host stays tailnet-only. `auth.lab` must be public too
for the portal. New `*.lab` hosts need the HTTP-01 cert trick
(`letsencrypt-http` + `lab-passthrough.sh`). Geo-lock (FR+DE) can sit on the
raspy2 leg.
- **Authelia rule**: a `domain: <guest host>` rule for `group:guests`
(`two_factor` for the owner if `ai-agent.lab` itself goes public).
- **Stale bypass**: the `ai-agent-flags` router (`/api/flags`, `api-chain`, no
Authelia) has no backend route any more; once identity matters, "via Traefik
with no `Remote-User`" must mean *anonymous ⇒ 401*, and that router should go.
### 2. Backend — identity, grants, enforcement
- **`core/auth.py` → a `Caller`** (`user`, `role: owner|guest|service`,
`projects`), injected as a FastAPI dependency next to `State`. Mapping:
Traefik + `Remote-User` ⇒ that user; Traefik without it ⇒ 401; loopback /
gateway / `INTERNAL_API_TOKEN` ⇒ `service` (= owner, as today); read-only key
⇒ owner, read-only; the `claude` Authelia account ⇒ owner (screenshots).
- **Users & grants store** — `/data/users.json`: `{user: {role, email,
projects: {slug: level}, budget, models}}`, `GET /api/me`, admin CRUD under
`/api/users` + a Settings → Users page.
- **Route classes** (full table in the exploration notes): admin-only routers
(`accounts`, `agents`, `cron`, `workers`, `files` editor/bundle, `memories`,
`templates`, `services`, `settings` hooks/webhooks/mail-trigger, `dashboards`
context, `project-env`) get an `owner_only` dependency — default-deny, guests
opt *in* per router. Project-scoped reads filter; code-executing writes
check.
- **Conversation ownership**: spawn stamps `meta.owner` + `meta.projects` (it
stamps neither today — `projects` only picks the account). Fork/cron inherit.
One choke point covers most per-conversation routes
(`conversations/catalog._resolve_transcript` / `_summary_and_meta`); the
routes that walk `all_summaries()` (search index, subagents, notifications,
dashboards) each need the filter, and the derived `homelab` fallback
attribution must not grant anything.
- **Spawn/resume/fork/interrupt/message for a guest**: the server derives `cwd`
from a granted slug (never the client's), pins account / worker / harness /
model to the guest's allowlist, and rebuilds the prompt's guideline block
server-side (the composer assembles it in the browser today, so a guest could
write any guideline they like).
- **Forms & asks**: `answeredBy` + an authorization check; a form raised by a
guest's run is answered by that guest (and/or the owner).
- **Per-user state**: namespace `ui-state` keys by user (split
`ai-agent-settings` into owner config vs personal prefs), key
`notif-read.json` by user, tag `uploads/<batch>` with an owner.
- **SSE**: per-subscriber filter in `events.Hub` — `transcript` events carry
cwd-encoded ids that leak project names and session ids.
### 3. Runner — the real boundary
| option | boundary | cost |
|---|---|---|
| **A. Sandbox container per guest**, paired as a worker | real: only the granted projects mounted, own API key, no host `~/.claude`, no docker socket, egress filtered (block LAN, `:8096`, `:8790`) | needs the in-container runner fixed (see below), a guest worker image, per-guest pairing; deploy/push need a scoped credential or an owner approval |
| B. Host sidecar, CLI-restricted (`--restricted`, tool allowlist, `--setting-sources`, cwd = project) | defence in depth only — same uid, same filesystem; no Bash ⇒ no builds/tests | small |
| C. Host sidecar as a dedicated Unix user | real filesystem boundary | root setup per guest, shared project dirs via groups |
| D. Today's mail-agent model (agent asks the owner first) | prompt-level only — not a boundary | none |
The worker protocol (`worker/`, `backend/workers/`) already gives A most of
its plumbing: the hub pulls (`/feed`, `/outbox`), the worker never calls back,
routing is a field. What's missing is a server-enforced *user → worker*
mapping, one container per guest (runs share the container's uid, so a shared
pool lets guests steer each other), and `/feed` scoped per user.
Whatever the choice, every guest run also needs: no `SIDECAR_TOKEN` in its
env, no `--remote-control` (guest sessions would show up in the owner's
claude.ai list), `--max-budget-usd` / `--max-turns`, and `crossSessionInbound`
off unless the session belongs to the caller.
### 4. Billing & models
Running other people's sessions on the owner's personal Max login spends the
owner's 5 h/weekly limits, and is likely outside what that plan allows (it's a
personal seat). The cleaner options are a dedicated Anthropic API key (Console
spend limit) for the guest runner, the guest's own Claude login inside their
sandbox, or local models through pi (free, weaker; pi only runs on the host
sidecar today). Per-guest caps: `$`/month + a model allowlist.
### 5. Frontend
- `useMe()` → route guards (`<OwnerOnly>` around the admin routes in
`root.tsx`), nav filtering (`BottomBar` `TABS`, `nav.ts` `SUB_SECTIONS`,
`Drawer` panels), composer lockdown (account, worker, harness/model, location
tags, guideline chips, skill/agent mentions, the guidelines master switch).
- Guest pages: home (their waiting forms + their running convs), conversation
list + thread, granted project pages (without `EnvEditor`).
- Thread: hide account/worker/cron/agent badges, `/plans` `/memories` `/_/`
links and the account-login fix for guests; cost per turn is a product call.
- Per-user caches: the IndexedDB query cache (`ai-agent-rq-cache`) and the
RichInput drafts need a user-keyed buster so a shared device doesn't leak.
- Notifications for guests: none today. In-app (SSE + badge) is free; email via
the mail service is cheap; Web Push needs VAPID + a service-worker handler.
### 6. Mail trigger
The mail-trigger allowlist is the current "guest" list. Natural endpoint:
derive it from the users store (a guest's DKIM-verified mail spawns a
conversation **they** own, in their sandbox, scoped to their projects), and
drop the separate `mail-trigger-senders.json`.
## Findings worth fixing regardless
- **`SIDECAR_TOKEN` is inherited by every run** (`sidecar/claude_accounts.py`
copies `os.environ`) — any session can drive `/spawn` / `/message` /
`/interrupt` / account login on the host sidecar directly.
- The host sidecar **binds `0.0.0.0:8790`** (bearer-protected; `/health` open).
- **The in-container runner can't start**: the `Dockerfile` copies 11 sidecar
modules but not `outbox.py`, `skills_list.py`, `stop_guard.py`, which
`sidecar.py` / `claude_accounts.py` import — the standalone image
crash-loops on import when `RUNNER_IN_CONTAINER=1`. `runner/` (pi) and `cli/`
(notify) aren't in the image either.
- The `ai-agent-flags` Authelia bypass in the homelab shim's `traefik.yml` is
dead.
## Suggested phasing
1. **Hardening** (no behaviour change for the owner): the four findings above.
2. **Identity + enforcement**: `Caller`, users/grants store, `/api/me`,
owner-only routers, conversation ownership, per-user state, SSE filtering,
frontend guards. Shippable dark: with no guest user, nothing changes.
3. **Guest runner**: the sandbox (option A), server-built spawn, budgets.
4. **Edge**: guest host + Authelia rule + SMTP notifier + first invite.
5. **Comfort**: guest notifications, mail-trigger unification, shared threads.