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>
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:
- The API must know who is calling and filter/deny accordingly.
- 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,.envfiles and SSH keys, drive the sidecar with the inheritedSIDECAR_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, aguestsgroup) are the cheapest path — the backend readsRemote-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.labthrough 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.labmust be public too for the portal. New*.labhosts 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 forgroup:guests(two_factorfor the owner ifai-agent.labitself goes public). - Stale bypass: the
ai-agent-flagsrouter (/api/flags,api-chain, no Authelia) has no backend route any more; once identity matters, "via Traefik with noRemote-User" must mean anonymous ⇒ 401, and that router should go.
2. Backend — identity, grants, enforcement
core/auth.py→ aCaller(user,role: owner|guest|service,projects), injected as a FastAPI dependency next toState. 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; theclaudeAuthelia 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,fileseditor/bundle,memories,templates,services,settingshooks/webhooks/mail-trigger,dashboardscontext,project-env) get anowner_onlydependency — 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 —projectsonly picks the account). Fork/cron inherit. One choke point covers most per-conversation routes (conversations/catalog._resolve_transcript/_summary_and_meta); the routes that walkall_summaries()(search index, subagents, notifications, dashboards) each need the filter, and the derivedhomelabfallback attribution must not grant anything. - Spawn/resume/fork/interrupt/message for a guest: the server derives
cwdfrom 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-statekeys by user (splitai-agent-settingsinto owner config vs personal prefs), keynotif-read.jsonby user, taguploads/<batch>with an owner. - SSE: per-subscriber filter in
events.Hub—transcriptevents 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 inroot.tsx), nav filtering (BottomBarTABS,nav.tsSUB_SECTIONS,Drawerpanels), 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_TOKENis inherited by every run (sidecar/claude_accounts.pycopiesos.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;/healthopen). - The in-container runner can't start: the
Dockerfilecopies 11 sidecar modules but notoutbox.py,skills_list.py,stop_guard.py, whichsidecar.py/claude_accounts.pyimport — the standalone image crash-loops on import whenRUNNER_IN_CONTAINER=1.runner/(pi) andcli/(notify) aren't in the image either. - The
ai-agent-flagsAuthelia bypass in the homelab shim'straefik.ymlis dead.
Suggested phasing
- Hardening (no behaviour change for the owner): the four findings above.
- 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. - Guest runner: the sandbox (option A), server-built spawn, budgets.
- Edge: guest host + Authelia rule + SMTP notifier + first invite.
- Comfort: guest notifications, mail-trigger unification, shared threads.