Per review: the project select is a compact pill, and the guest's other project chips stay — the picked one leaves the chip row, the rest ride along as extra projects (sent after the pick). A chip turned on while nothing is picked becomes the pick. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
258 lines
16 KiB
Markdown
258 lines
16 KiB
Markdown
# Multi-user — what it would take
|
|
|
|
Status: **round 1 shipped** (2026-10-11): hardening + identity + enforcement,
|
|
dark until a guest exists. 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.
|
|
|
|
## 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.
|