19 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), 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/flagsAuthelia bypass is gone from the homelab shim. - Identity:
backend/users/— guests store, built-in magic-link login,Calleron every request,GUEST_ROUTESdefault-deny,GUEST_HOSTSnever-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.xyzto 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 ownu.<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 readshared:<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/skillsis 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/requestslists 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,
PushSubscriptionper user) — the channel is stored, not delivered. - Bash confinement:
GUEST_SANDBOX=1needssudo apt install bubblewrap socaton the host; then test deploys from inside the sandbox. - Mail trigger fold: derive the allowlist from
users.jsonand stamp the mailed conversation'sowner. - 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:
- 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). 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_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.