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>
171 lines
11 KiB
Markdown
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.
|