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

11 KiB

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.