retire the single-user claim from the docs it outlived
CLAUDE.md asserted "single-user is a hard invariant, not a stage" while users held six rows and role_capabilities held grants. Every doc that repeated it is corrected here, in prose and in the code comments that carried the same claim. The accurate statement is narrower: one owner who bypasses every check, other accounts holding only what their role is granted, and a set of capabilities — terminal, chat, files, tasks, items, desktop, browser — that are structurally ungrantable because they execute as the owner's OS user. TODO.md gains a Multi-user section for what the read turned up: no way to create a second account, dashboards.id colliding across users, authorize.ts untested, pty/vault/opencode taking no identity, Radicale still owner_only. claude-sidecar-isolation.md's open question is answered rather than left open — the per-email spawn model is dead weight, because chat is an execution capability and no second account can ever reach it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -284,12 +284,17 @@ is a real design decision and I don't have a confident recommendation.
|
||||
|
||||
## Open questions — the ones I'd rather you answered
|
||||
|
||||
1. **Is the per-email spawn model dead weight?** `CLAUDE.md` states single-user is a hard invariant
|
||||
("If a change seems to need 'which user is this', the answer is always the owner"), yet the agent
|
||||
sidecar is keyed per email — `claude:${email}`, a `claudeProcs` Map, a `claudeSpawnWaiters` Map, a
|
||||
per-email PID lock. If there is only ever one owner, Stage 1a becomes trivial: one PM2 entry, no
|
||||
fan-out, no registration polling. If you intend multi-tenant later, the fan-out has to stay and
|
||||
Stage 1 gets harder. **This single answer changes the shape of the whole plan.**
|
||||
1. ~~**Is the per-email spawn model dead weight?**~~ — **answered 2026-08-07: yes, it is.** The
|
||||
question was whether multi-tenancy might later need the per-email fan-out (`claude:${email}`, the
|
||||
`claudeProcs` and `claudeSpawnWaiters` Maps, the per-email PID lock). The capability model settled
|
||||
it in the *other* direction from what "the platform is going multi-user" would suggest: `chat` is
|
||||
`kind: 'execution'` in `capabilities/registry.ts`, which is **never grantable at any level**,
|
||||
because the agent runs as the owner's OS user with `--dangerously-skip-permissions`. Additional
|
||||
accounts exist now, and not one of them can ever open a chat.
|
||||
|
||||
So the fan-out is keyed on a dimension that is structurally guaranteed to have one value. Stage 1a
|
||||
is the trivial version: one PM2 entry, no fan-out, no registration polling. This only reopens if
|
||||
per-user home confinement is ever built, which is a project rather than a checkbox.
|
||||
|
||||
2. **Relay or redirect?** Officer proxies the agent WebSocket (one origin, keeps your HTTPS reverse
|
||||
proxy and JWT model intact, but a restart still drops the socket for a moment), or officer hands
|
||||
|
||||
+2
-1
@@ -117,7 +117,8 @@ worth serving both from one place.
|
||||
- **It is not backup.** Sync propagates deletions. A synced folder is not a backup of itself, and
|
||||
anyone who believes otherwise finds out at the worst moment. Versioning (Syncthing has several
|
||||
strategies) should be enabled and surfaced in the UI precisely so this is not confused.
|
||||
- **It is not sharing.** Single-user remains a hard platform invariant.
|
||||
- **It is not sharing.** Files is an `execution` capability — the owner's disk, never grantable — so
|
||||
there is still nobody to share with, whatever the account list says since 2026-08-07.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -9,8 +9,11 @@ sidecar with its own scheduling, so it does not appear in the Jobs list.
|
||||
created over REST, streamed live over WebSocket, resumable/attachable, visible on desktop *and* phone,
|
||||
and ending in a push notification. Replaces today's ephemeral script-task WebSocket path.
|
||||
|
||||
**Context:** single user, forever. No multi-tenant concerns — "is anything running?" is a global check.
|
||||
Favor power-user affordances over guardrails. See memory `sole-user-assume-competence`.
|
||||
**Context:** jobs belong to the owner. Not because the platform is single-user — it stopped being that
|
||||
on 2026-08-07 — but because `tasks` is an `execution` capability: running a job means running a script
|
||||
as the owner's OS user, so it can never be granted to a member. "Is anything running?" is therefore
|
||||
still a global check, and the conclusion below is unchanged even though the premise was rewritten.
|
||||
Favor power-user affordances over guardrails.
|
||||
|
||||
## Current state (baseline)
|
||||
|
||||
|
||||
@@ -58,7 +58,9 @@ speaks DAV.
|
||||
|
||||
### 1.2 The credential model
|
||||
|
||||
- **Username** = the account's email address. (Officer is single-user; there is exactly one.)
|
||||
- **Username** = the account's email address — the signed-in account's own, not a constant. (This said
|
||||
"Officer is single-user; there is exactly one" until 2026-08-07. `calendar` is now a grantable
|
||||
capability, so a member can hold their own app passwords and their own collections.)
|
||||
- **Password** = a **DAV app password**, not the login password.
|
||||
|
||||
DAV app passwords are argon2-hashed at rest, scoped to `/dav` and nothing else, and **the plaintext is
|
||||
@@ -100,9 +102,12 @@ A collection https://<host>/dav/<userId>/<collection>/
|
||||
`<userId>` **is the platform user id, by construction** — the same integer `/auth/me` returns. They
|
||||
cannot diverge: `sync-router.ts` sets `X-Officer-User: String(userId)` straight from the app-password
|
||||
row, the sidecar forwards it to Radicale as `X-Remote-User`, and Radicale's storage tree is literally
|
||||
`/<that value>/`. There is no mapping table to get out of step. On a single-user instance — which every
|
||||
Officer instance is — that is `1`. Deriving it from `/auth/me` is safe; so is deriving it from the
|
||||
collection paths, which is why both work today.
|
||||
`/<that value>/`. There is no mapping table to get out of step.
|
||||
|
||||
**Do not hardcode `1`.** This passage used to say that on a single-user instance — "which every Officer
|
||||
instance is" — the value is always `1`. That stopped being true on 2026-08-07: members can hold the
|
||||
`calendar` capability, and a member's id is not 1. Derive it from `/auth/me` or from the collection
|
||||
paths; both work, and both stay correct when the caller is not the owner.
|
||||
|
||||
**A collection cannot live outside `/dav/<userId>/`.** Two independent guards: the sidecar rejects any
|
||||
`collection` outside that prefix, and Radicale runs `rights type = owner_only`.
|
||||
|
||||
@@ -135,8 +135,9 @@ Rationale for not reusing the account password: it ends up typed into a phone, s
|
||||
account manager in recoverable form, and synced to whatever backs that phone up. One password per
|
||||
device, revocable per device, is the whole point.
|
||||
|
||||
The single-user invariant holds — every app password belongs to the owner. `user_id` is there for
|
||||
referential integrity, not multi-tenancy.
|
||||
`user_id` was described here as "referential integrity, not multi-tenancy". That is no longer true:
|
||||
since 2026-08-07 `calendar` is a **grantable** capability, so an app password can belong to a member
|
||||
and the column decides whose collection tree Radicale serves. It is load-bearing.
|
||||
|
||||
---
|
||||
|
||||
@@ -182,8 +183,9 @@ Naming these now so they do not creep in later:
|
||||
- **iTIP/iMIP scheduling** — sending invitations and processing RSVPs by email. Genuinely complex, and
|
||||
a single-user personal calendar mostly consumes invitations rather than issuing them. Revisit only
|
||||
on a concrete need.
|
||||
- **Sharing, ACLs, federation** — single-user is a hard invariant of this platform. There is nobody
|
||||
to share with.
|
||||
- **Sharing, ACLs, federation** — still out of scope, but the reason weakened on 2026-08-07. Members
|
||||
can now hold `calendar`, so there is somebody to share with; what is missing is any notion of one
|
||||
account granting another access to its own collection. Revisit on a concrete need.
|
||||
- **Reimplementing RRULE on the server.** The sidecar stores what the client sends. Expansion happens
|
||||
where it is displayed, using a library.
|
||||
- **A NextCloud-compatible API.** Nothing needs to pretend to be NextCloud. The standards are the
|
||||
|
||||
@@ -13,10 +13,20 @@ officer/
|
||||
└── data/ runtime state — NOT version controlled
|
||||
```
|
||||
|
||||
Officer is a self-hosted personal platform: an AI agent, a terminal, a file browser, a code editor,
|
||||
email, a bitcoin wallet, a remote desktop and dashboards, behind one web app. **It serves exactly one
|
||||
person — the owner of this server.** There is no tenancy, no roles, no other users. If a question
|
||||
turns on "which user", the answer is the owner.
|
||||
Officer is a self-hosted platform: an AI agent, a terminal, a file browser, a code editor, email, a
|
||||
bitcoin wallet, a remote desktop and dashboards, behind one web app. **It is built around one owner**
|
||||
— user id 1, role `Super Admin`, who bypasses every permission check — and since 2026-08-07 also
|
||||
admits **additional accounts holding a strict subset of it**, governed by per-role capability grants.
|
||||
|
||||
So "which user" has two answers depending on the surface. For the **app** capabilities (gitea, music,
|
||||
photos, email, calendar…) it is a real question with a real answer. For anything that executes code or
|
||||
touches the disk — terminal, chat, tasks, files, desktop, browser — it is still always the owner:
|
||||
those are `kind: 'execution'` in `platform/src/servers/capabilities/registry.ts` and can never be
|
||||
granted, because they run as the owner's OS user in the owner's home.
|
||||
|
||||
This paragraph said "there is no tenancy, no roles, no other users" until 2026-08-07. Four roles exist
|
||||
and five non-owner accounts are live; treat the capability registry as the source of truth over any
|
||||
prose, here or elsewhere.
|
||||
|
||||
`platform/` and `capabilities/` each have their own `CLAUDE.md` with detail. This file is the layer
|
||||
above them: where things live, how to change them safely, and the things that are true of the running
|
||||
|
||||
Reference in New Issue
Block a user