Files
ai-agent/docs/multi-user.md
2026-10-12 22:16:03 +02:00

17 KiB

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) and 3 (per-user notifications, spend, impersonation) 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.

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.