Files
ai-agent/docs/multi-user.md
2026-10-12 23:42:08 +02:00

304 lines
19 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Multi-user — what it would take
Status: **round 1 shipped** (2026-10-11): hardening + identity + enforcement,
dark until a guest exists; rounds 2 (guest settings, shared skills), 3
(per-user notifications, spend, impersonation) and 4 (guests start projects
from templates, hosted on raspy2) on top. Decisions from form `frm_ae19037c84dc` first, then
what's built and what's left; the original exploration follows unchanged.
## Decisions
| topic | call |
|---|---|
| first guests | Dom + Claire → `lost-in-production`; Dom → `sherlock-project`; Celvia → `calamite` |
| reach | a separate public host, `claude.lab.gabvdl.xyz` (guest surface) |
| identity | **built-in login** (the ai-agent's own, not Authelia) — magic link by mail; no Authelia SMTP |
| runner | prompt-level trust on the **same** host sidecar and account, plus **per-session write permissions** (Claude Code `--settings` permissions for that run) |
| guest capabilities | read, edit, bash, commit, web, prod deploy, subagents |
| approvals | none — "I trust my guests" |
| grants | per project, levels `view` · `chat` · `ship` |
| visibility | own conversations + the ones explicitly shared |
| shared threads | yes, in v1 — author shown per message |
| guest UI | list, thread, composer, project page, diff, tool details, cost |
| user admin | Settings → Users page |
| billing | the owner's personal account; models opus/sonnet/haiku; default cap $500/month |
| guest notifications | in-app, email, web push — **each user picks their channel(s)** |
| owner pinged | when a guest starts a conversation |
| forms from a guest run | go to the guest |
| mail trigger | fold into the users list (later phase) |
| hardening | all four fixes below |
| this round | hardening + identity & enforcement, shipped dark |
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`).
## Built in round 1
- **Hardening**: runs no longer inherit `SIDECAR_TOKEN`; the host sidecar
binds the docker bridge (`install.sh`, `SIDECAR_BIND`); the image ships every
sidecar module + the notify CLI (standalone runner works again); the dead
`/api/flags` Authelia bypass is gone from the homelab shim.
- **Identity**: `backend/users/` — guests store, built-in magic-link login,
`Caller` on every request, `GUEST_ROUTES` default-deny, `GUEST_HOSTS`
never-owner surface, same-origin writes. Settings → Users, `/me`, `/login`.
- **Enforcement**: conversation / form / ask / push / project / diff / asset
filtering, per-user ui-state, per-subscriber SSE; guest spawn pinned
server-side with the sidecar **scope** (dontAsk + edits confined to the
project, verified with a real run); author tags + shared threads + Share
popover; guest pushes → their channels (in-app, email), owner pinged on a
guest start.
- **Homelab**: guest router `claude.lab.gabvdl.xyz` (no Authelia, own rate
limit, forward-auth headers stripped), `GUEST_*` + `SMTP_*` env.
- **Public** (2026-10-11, round 1b): raspy2 routes `claude.lab.gabvdl.xyz` to
the homelab (`services/ai-agent/raspy2-public.yml`, `scripts/public-route.sh
ai-agent on|off`; :80 HTTP, :443 TCP passthrough). The admin host stays
tailnet-only. Owner-sent links are 3-day invitations with an invite mail.
Not geo-locked: a passthrough can't carry raspy2's HTTP ipAllowList.
## Built in round 2 (2026-10-11): guest settings + shared skills
- **Settings for guests**: `/settings` (theme, currency, timezone, roaming
avatar — a strip URL, not the server's one emote-set import) and
`/settings/shortcuts`, all in their own `u.<id>.` ui-state. The bar's *You*
tab opens Profile / Settings / Shortcuts.
- **Prompt shortcuts**: a new guest starts from the owner's **starting
shortcuts** (Settings → Users → *Every guest*; default: Commit, Ask me,
Notify — nothing lab-specific) and edits their own copy; Reset goes back.
Their composer shows them as chips.
- **Sharing skills, owner → guests**: a guest run starts in its project, so it
only loaded that project's `.claude/skills` — none of the lab's. The
owner's **shared skills** (default: ask-form, notify-done, screenshot,
page-scrape) are handed to every guest run as a generated plugin
(`--plugin-dir`), so they read `shared:<name>`; toggled per skill in the
Skills catalog or listed in *Every guest*. Skill scripts run with the
owner's environment — same prompt-level trust as Bash.
- **Sharing skills, both ways, per project**: a skill committed in a
project's `.claude/skills` is shared by construction — every run there
(owner's or guest's) loads it, and the guest composer mentions it. That's
the path for a skill a guest writes, or one specific to a collaboration.
## Built in round 3 (2026-10-12): per-user notifications, spend, impersonation
- **Notifications follow the conversation's audience**, not its owner alone:
whoever started it and everyone it's shared with, each on their own
channels — the owner on the webhooks (the phone), a guest in-app + email.
The owner sees every conversation but only *hears* about a guest's one once
they follow it — by writing in it, or with the bell in its header; anyone
can mute a conversation from the same bell (`meta.follow`). The feeds, the
desk phone's unread list and *waiting for feedback* are per user, the
owner's included.
- **Settings → Users** shows each guest's spend this month against their
budget on the card header (all-time total in the tooltip), and a month
summary across guests.
- **Impersonate** a guest from their card: the admin browser gets a 4-hour
guest session for them and sees exactly their app, under a *Viewing as* bar
with *Back to admin*. What's done there is done as the guest (a spawn is
theirs, on their budget) — it's for testing the guest view.
## Built in round 4 (2026-10-12): guests start projects
Gabriel's calls (form `frm_6c800ecb8272`): a per-guest toggle; a quota of 10;
frontend templates with no approval, a backend template (`vite-react-pocketbase`
— a PocketBase container on raspy2) after the owner's OK; guests host under
their own bucket only, `<name>.<guest>.gabvdl.xyz` (a guest's own domain later);
public by default, password or FR + DE as options; **a Gitea account per guest**
(made on their first project, never logged into) holding their repos; the
creator gets *ship*; the same conversation carries on inside the new project;
and the same form for the owner — it lives in the `new-frontend-project` skill,
shared with guests.
- *+ New project* in the guest composer's project pill (or any run asked for
one): the run reads the idea and opens the **New project** form pre-filled
with its guesses (`notify new-project`), then ends its turn.
- On submit the app checks shape and policy, then the host (name, address, repo
free — else a corrected form comes back), asks the owner for a backend
template, scaffolds through the sidecar, grants the guest, and moves the
session into `~/projects/<name>` (`/relocate` + resume) with an app turn
listing what was made; the agent builds v0 and deploys to raspy2.
- Settings → Users: *Can create projects* + bucket per guest, *Every guest →
New projects* for the policy; `/api/new-project/requests` lists them.
- Fixed on the way: a guest's turn into an existing conversation resumed with
no cwd, i.e. in the lab checkout (its CLAUDE.md loaded; Dom's sherlock
thread shows it) — it now resumes where the session lives.
## Left
- **Web Push** for guests (VAPID keys, a push handler in the service worker,
`PushSubscription` per user) — the channel is stored, not delivered.
- **Bash confinement**: `GUEST_SANDBOX=1` needs `sudo apt install bubblewrap
socat` on the host; then test deploys from inside the sandbox.
- **Mail trigger fold**: derive the allowlist from `users.json` and stamp the
mailed conversation's `owner`.
- **Budget**: enforced at spawn/resume from the month's summed cost; no
mid-run cap yet (`--max-budget-usd`).
- Per-guest skill lists and guest-owned personal skills (adopted into the lab
by the owner) — not built: one global list + project skills cover the
first guests.
- A guest's message into a *live* run of an unscoped (owner) conversation goes
through the inbox and inherits that run's permissions until it relaunches.
## 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).
A mandatory project pill above the text area picks the run's cwd; the
other granted project chips can ride along as extra write scope.
- 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.