merge sidecar-app-store: per-user Claude

A member's agent turn now runs as their own Linux account, with their own claude
install, their own ~/.claude credential, their own transcripts and sessions that
record whose they are. Verified end to end on the production host: uid 1001,
nine environment variables, zero ANTHROPIC_*, zero POSTGRES_URL, zero
JWT_SECRET.

The two owner-only refusals that held chat closed to members — the wholesale
isSuperAdmin middleware in api/chat/chat.ts and the socket's 403 in server.tsx —
are gone, removed together once the turn ran under runAs.

Also carries a live credential fix that predates this work: mcp-host.json held
the owner's 30-day JWT at 0644 inside a 755 directory on a host where every role
has a shell. Now 0600 plus an explicit chmod, since writeFileSync's mode is
ignored on an existing file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-12 01:58:14 +00:00
co-authored by Claude Opus 5
128 changed files with 8681 additions and 552 deletions
+3
View File
@@ -48,3 +48,6 @@ src/apps/officer-web/index.gen.html
# scratch scripts — never commit these # scratch scripts — never commit these
*.tmp.ts *.tmp.ts
# Sidecar assets published at install time — copies of files that live in each sidecar's own tree.
public/plugins/
+41 -6
View File
@@ -126,7 +126,7 @@ history was deleted because it had drifted from the real schema. Treat the schem
files, as the source of truth. files, as the source of truth.
**Declare multi-column uniqueness as `uniqueIndex('uq_…').on(a, b)`, never `unique('uq_…').on(a, b)`** **Declare multi-column uniqueness as `uniqueIndex('uq_…').on(a, b)`, never `unique('uq_…').on(a, b)`**
drizzle-kit mis-diffs named composite unique *constraints* and re-creates them on every push, which used drizzle-kit mis-diffs named composite unique _constraints_ and re-creates them on every push, which used
to stop `db:push` on an unanswerable truncate prompt. Same for any foreign key whose generated name would to stop `db:push` on an unanswerable truncate prompt. Same for any foreign key whose generated name would
exceed Postgres's 63-character identifier limit: name it explicitly. See `src/databases/CLAUDE.md` exceed Postgres's 63-character identifier limit: name it explicitly. See `src/databases/CLAUDE.md`
"Composite keys" before adding either. "Composite keys" before adding either.
@@ -170,7 +170,7 @@ ahead of everything, and it re-verifies the token itself so it covers routes tha
survive the next door; refusing to boot does. survive the next door; refusing to boot does.
So **adding a router means adding one line to `CAPABILITIES`**. If the surface genuinely is not So **adding a router means adding one line to `CAPABILITIES`**. If the surface genuinely is not
user-gated, add it to `EXEMPT_API_PREFIXES` in `totality.ts` *with a reason* — an unexplained exemption user-gated, add it to `EXEMPT_API_PREFIXES` in `totality.ts` _with a reason_ — an unexplained exemption
is how the hole happened the first time. is how the hole happened the first time.
The frontend hook `useCapabilities` **fails open** on purpose: hiding a dock icon is a courtesy, the The frontend hook `useCapabilities` **fails open** on purpose: hiding a dock icon is a courtesy, the
@@ -219,12 +219,11 @@ so it rewrites every uncommitted file — including work in progress that isn't
up as unexplained whitespace churn in someone else's diff. Run `bunx prettier --write <paths>` on the up as unexplained whitespace churn in someone else's diff. Run `bunx prettier --write <paths>` on the
files you actually touched. `bun format` is only safe when the tree is otherwise clean. files you actually touched. `bun format` is only safe when the tree is otherwise clean.
## Code Style ## Code Style
- **Paradigm**: functional — pure functions, immutability, composition - **Paradigm**: functional — pure functions, immutability, composition
- **TypeScript**: strict, no `any`. Type-only imports are required (`verbatimModuleSyntax`). - **TypeScript**: strict, no `any`. Type-only imports are required (`verbatimModuleSyntax`).
- **Comments**: minimal, and about *why*. Don't narrate what the code already says. - **Comments**: minimal, and about _why_. Don't narrate what the code already says.
- **Async**: always async/await - **Async**: always async/await
- **Exports**: named only, no defaults - **Exports**: named only, no defaults
- **Files**: `PascalCase.tsx` for components, `kebab-case.ts` for everything else - **Files**: `PascalCase.tsx` for components, `kebab-case.ts` for everything else
@@ -281,6 +280,13 @@ type parameter and let inference flow from it.
- **Stay focused** — note unrelated problems, don't fix them uninvited - **Stay focused** — note unrelated problems, don't fix them uninvited
- **Report honestly** — say what you verified and what you didn't - **Report honestly** — say what you verified and what you didn't
**Always commit and push when you finish implementing — including when it went wrong.** Don't wait to be
asked, and don't hold a branch back because it is unfinished, untested or turned out to be a dead end. The
history of the mistakes is worth having: a reverted commit and its message explain why an approach was
abandoned, which is exactly the thing that gets lost when a failed attempt is quietly discarded. Say what
state it is in — in the commit message and in `COMMS/` if another agent will pick it up — rather than
withholding the commit until it is good.
Commit messages: simple lowercase, no prefixes. Commit messages: simple lowercase, no prefixes.
## Frontend route conventions (apply to EVERY new dashboard route) ## Frontend route conventions (apply to EVERY new dashboard route)
@@ -316,7 +322,7 @@ link-focusable). Half the app still does this; none of the new code should.
`f35c145`); **react-router's `<NavLink>`** for nav chrome, so active state comes from the router. `f35c145`); **react-router's `<NavLink>`** for nav chrome, so active state comes from the router.
The hand-rolled `isActive` in `Dock`/`Header` is scheduled for replacement (audit Phase 4) — don't The hand-rolled `isActive` in `Dock`/`Header` is scheduled for replacement (audit Phase 4) — don't
copy it. A disabled entry renders as a `<span>`; a disabled `<a>` is not a thing. A control that copy it. A disabled entry renders as a `<span>`; a disabled `<a>` is not a thing. A control that
*mutates* rather than navigates stays a `<button>`. _mutates_ rather than navigates stays a `<button>`.
- **Route pairs.** A bare screen route plus a param route rendering the same component: `/chat` + - **Route pairs.** A bare screen route plus a param route rendering the same component: `/chat` +
`/chat/:sessionId`, `/jobs` + `/jobs/:id`, `/email` + `/email/:emailId`, `/headscale` + `/chat/:sessionId`, `/jobs` + `/jobs/:id`, `/email` + `/email/:emailId`, `/headscale` +
`/headscale/:section`. One `<Navigate … replace />` guard in the screen, placed after all hooks, `/headscale/:section`. One `<Navigate … replace />` guard in the screen, placed after all hooks,
@@ -332,13 +338,42 @@ link-focusable). Half the app still does this; none of the new code should.
re-exported from `src/workspaces/officerdev/src/index.ts` (named exports only — the barrel re-exported from `src/workspaces/officerdev/src/index.ts` (named exports only — the barrel
deliberately avoids `export *` for app modules to keep `appRegistryMetas` from colliding). deliberately avoids `export *` for app modules to keep `appRegistryMetas` from colliding).
## COMMS — a channel between agents, when one is open
`COMMS/<work-stream>/` is a **tracked** channel between agents working on this repo from different machines.
It exists because findings used to reach each other by the owner relaying them from memory at the end of long
sessions.
**There is no open channel right now.** `COMMS/sidecar-app-store/` ran for one night — per-user Linux
accounts through to a member's first agent turn — and was deleted when the work landed, which is the
convention rather than an oversight: a spent channel left in place gets read as current.
If you open one:
- **Read it before starting**, if your task touches its work stream. It carries what is verified, what is
assumed, what is broken, and what is waiting on a decision — the parts a commit message does not hold.
- **Number the files and alternate**, one per turn, odd for one agent and even for the other. The parity is
the author; the alternation is the protocol. A push with no doc is then visibly a break rather than
something to find by diffing, and "nothing to report" is still a turn worth taking — silence and a crashed
agent read identically.
- **End on a checkable condition**, not on either party's judgement: no open item is actionable by a
participant. "I think we're done" can close a thread with work still in it.
- **Durable reasoning goes in `docs/` or next to the code.** The channel is for coordination. When the work
lands, delete the channel and move anything still open to `TODO.md`.
Two things that made it work, and neither is about either agent being more careful. One writes, the other
verifies, and only the verifier runs things on a real machine — most of what was caught was invisible to
reading and needed a live filesystem. And the author of a comment is the worst-placed person to notice the
code disagrees with it: the two most serious defects were both found by whoever had not written the sentence
explaining why it was safe.
## Further Reading ## Further Reading
- `docs/navigation-audit.md`**authoritative** on routing/navigation: the opaque-click anti-pattern, - `docs/navigation-audit.md`**authoritative** on routing/navigation: the opaque-click anti-pattern,
a severity-ranked findings table, the channel-selection map and the four-phase plan a severity-ranked findings table, the channel-selection map and the four-phase plan
- `docs/agent-coordination.md`**the north star** for the workspace/panel work: agents on one - `docs/agent-coordination.md`**the north star** for the workspace/panel work: agents on one
dashboard coordinating with each other instead of through the human, the handoff protocol, and what dashboard coordinating with each other instead of through the human, the handoff protocol, and what
is deliberately *not* being built. Read it before ranking, deferring or starting any panel item — is deliberately _not_ being built. Read it before ranking, deferring or starting any panel item —
it is what `docs/workspace-panel-todo.md` is ranked against. it is what `docs/workspace-panel-todo.md` is ranked against.
- `docs/workspace-panels.md` — how the Workspace/Panel framework works: the layout tree, how a panel is - `docs/workspace-panels.md` — how the Workspace/Panel framework works: the layout tree, how a panel is
mounted and what it knows, the URL-vs-channel split for panel-to-panel communication, and the mounted and what it knows, the URL-vs-channel split for panel-to-panel communication, and the
+41 -6
View File
@@ -14,10 +14,45 @@ the owner's OS user and can never be granted. Indirection there really is accide
## Multi-user ## Multi-user
- [ ] **No way to create a second account.** `createUser` has one call site, `auth/bootstrap.ts`, gated - [ ] **`deprovisionOsAccount` does not exist, and deleting a member leaves their whole Linux side.**
on an empty user table. There is no signup route, no invite flow and no admin create-user `deleteUserHandler` removes the row and cascades the database; `userdel` never runs. Observed on the
handler, so every member on this instance was inserted into Postgres by hand. This is the production host on 2026-08-12: a member deleted through the UI kept a working login shell, a running
blocker for onboarding anyone who is not already in the database. Postgres container and 454M of data, and their uid was free for the next `useradd` to reissue. Spec in
`docs/deprovision-os-account.md`. The ordering that matters: reap processes explicitly (`terminate-user`
does **not** reap a stale shell, and `userdel` fails while one lives), then `chown -R` to the service
user, then `userdel` — sever before release, and abort if the `chown` fails.
- [ ] **The terminal replays terminal QUERIES, which get typed into the shell.** `sidecar/pty/sessions.mjs`
replays the whole scrollback on attach; query sequences in the buffer get re-asked, xterm.js answers,
and the answers arrive as keystrokes. Visible to a member daily. Fix is to strip query sequences in
`appendBuffer`, so a replay reproduces output and never re-issues requests.
- [ ] **Agent sessions are not durable, and it is one property behind three symptoms.** A sidecar restart
loses session identity, which is why `endTurnIfAgentIsGone` must skip sessions with no recorded
`userId`, why a stuck "generating" spinner survives until a reconnect, and why any crash in that process
is destructive rather than merely inconvenient. Fixing the three separately would miss that they are one
missing property.
- [x] **No way to create a second account.** Fixed 2026-08-11 on `sidecar-app-store`: `POST /api/users`
(`api/users/create-user.ts`, owner-gated) plus an Add-account form in
Settings → User management. Created accounts are `status: 'Active'` — the column defaults to
`'Unverified'` and `signin.ts` refuses anything else with a bare UNAUTHORIZED, which is the trap
the hand-INSERT route fell into. The owner sets the password and reads it out; `passwordChangedAt`
stays null. Directories come from the shared `provisionUserDirs`/`USER_DIRS` in `data-path.ts`,
which `scripts/provision-user-dirs.ts` now imports rather than restating.
- [ ] **Still no invite flow, and no password reset for a member.** The owner types the password and
tells the person, which means the owner knows it and the member cannot change it back if they
forget theirs — recovery today is delete-and-recreate. An invite (token, expiry, member sets
their own) needs a mail path. This is the next piece, not a nice-to-have.
- [x] **A second Super Admin was storable, and made the owner nondeterministic.** Fixed 2026-08-11.
`ck_users_owner_is_super_admin` pins user 1's role but a row-level CHECK cannot see other rows, and
`updateUserRoleHandler` happily promoted anyone — while `getOwnerUser()` was
`WHERE role='Super Admin' LIMIT 1` with no ORDER BY. Two holders would have made "who owns this
server" a question the query plan answered, and that answer feeds the agent sidecar's identity,
vault access and origin scoping. Both write paths now refuse the role, the list endpoint offers
`assignableRoles` without it, and `getOwnerUser()` orders by id.
- [ ] **`dashboards.id` is a global primary key, and ids are `slugify(name)`.** Two accounts cannot - [ ] **`dashboards.id` is a global primary key, and ids are `slugify(name)`.** Two accounts cannot
both have a dashboard named "Home". Reachable today: six accounts exist. The recommendation on both have a dashboard named "Home". Reachable today: six accounts exist. The recommendation on
@@ -41,7 +76,7 @@ the owner's OS user and can never be granted. Indirection there really is accide
- [ ] **`pty`, `vault` and `opencode` receive no identity at all.** Every other sidecar validates - [ ] **`pty`, `vault` and `opencode` receive no identity at all.** Every other sidecar validates
`X-Officer-User`. The pty sidecar keys purely on a `sessionId` from the query string and its `X-Officer-User`. The pty sidecar keys purely on a `sessionId` from the query string and its
`/_officer/sessions` endpoints list and kill *every* session on the box; vault and opencode take `/_officer/sessions` endpoints list and kill _every_ session on the box; vault and opencode take
no user argument. All three are covered today only because `terminal`, `vault` and the agent are no user argument. All three are covered today only because `terminal`, `vault` and the agent are
owner-only capabilities — that is a correct outcome resting on the wrong layer, and it is the owner-only capabilities — that is a correct outcome resting on the wrong layer, and it is the
thing to fix first if any of them is ever granted. thing to fix first if any of them is ever granted.
@@ -51,7 +86,7 @@ the owner's OS user and can never be granted. Indirection there really is accide
side is ready for members; the CalDAV server underneath is not. side is ready for members; the CalDAV server underneath is not.
- [ ] **The music library is one global index.** `sidecar/music/indexer.ts` reads `HOME_DIR` and serves - [ ] **The music library is one global index.** `sidecar/music/indexer.ts` reads `HOME_DIR` and serves
every account from it. Favourites, playlists and now-playing *are* per-user. Deliberate for now every account from it. Favourites, playlists and now-playing _are_ per-user. Deliberate for now
(one household, one library) but worth stating rather than discovering. (one household, one library) but worth stating rather than discovering.
- [ ] **`markInterruptedJobs()` and `getOldestPendingJob()` are platform-wide.** The pipeline queue is a - [ ] **`markInterruptedJobs()` and `getOldestPendingJob()` are platform-wide.** The pipeline queue is a
+210
View File
@@ -0,0 +1,210 @@
# Deprovisioning a member's Linux account
**Status:** specification. Not implemented. Written from a manual teardown performed on the production host on
2026-08-11, so the ordering constraints below are measured rather than reasoned.
**Trigger for implementing:** before the first account that does not belong to the server owner. Not "after
per-user Claude" — the risk opens when a real person has an account that might later be deleted, which may or
may not be the same moment.
---
## What happens today
`deleteUserHandler` removes the `users` row and cascades the database. `userdel` never runs. Measured on a
real member (`green`, uid 1002) immediately after deleting them through the UI, before any cleanup:
| | after `deleteUserHandler` |
|---|---|
| `users` row | gone |
| Linux account | alive, uid 1002 |
| Login shell | `id -u` → 1002 — the deleted account still had a working login |
| Rootless Docker | daemon running, `postgres` container `Up 2 hours (healthy)` |
| Home + Docker storage | 454 MB intact |
| linger, `/run/user/1002`, `/etc/subuid`, `/etc/subgid` | all present |
Nothing breaks, which is what makes it dangerous. The account keeps working; only the platform forgets it
exists.
## Why it matters: uid reuse
`useradd` allocates the lowest free uid. Delete a member and the uid is free while their files still carry it,
so the next member created inherits the previous member's home, keys, Docker storage and anything else owned
by that number. By uid, not by any decision anyone made.
This is not hypothetical. `officer_jg` (uid 1001) and `green` (uid 1002) both had login shells pointing at one
home on this host, and the `users` table had no row for `officer_jg` at all — an earlier account for the same
email, deleted from the platform, whose Linux side survived. The adoption rule in `ensureOsUser` was never
bypassed; the account simply outlived the row.
**The invariant this function exists to guarantee:**
> After deprovisioning, no file anywhere is owned by the freed uid **or by any id in its freed subuid range**,
> and no passwd entry, linger marker, runtime directory or process refers to it.
## The subuid half, which is easy to miss
A member's rootless Docker storage is **not** owned by their uid. Container processes map through
`/etc/subuid`, so the files are owned by ids in that range — on this host, `green` had `231072:65536`, and
postgres's data directory was owned by `231141` (231072 + 70, postgres's inner uid in the Alpine image).
`userdel` releases the subuid range along with the uid, and a later account can be allocated the same range.
So a check for "nothing is owned by the freed uid" **passes while hundreds of megabytes are still owned by the
freed subuid range**, and a future member's containers would map onto another member's leftover files.
Any verification has to cover the range, not just the uid.
---
## The sequence
Ordering is load-bearing. Each step explains what breaks if it moves.
### 1. Disable linger, before stopping anything
```
loginctl disable-linger <user>
```
Lingering keeps a systemd user manager alive with no login session. Terminate first and linger can bring it
back; disable first and nothing can re-spawn between the two steps.
*(The manual teardown ran these in the opposite order and worked. This order is specified because it removes a
race rather than because the other one failed.)*
### 2. Terminate the session, then **verify it actually died**
```
loginctl terminate-user <user>
```
**`terminate-user` is not a barrier.** Measured: a `/bin/zsh -i` owned by the member survived it — three hours
old, still running after the session was terminated and `/run/user/<uid>` was removed. `userdel` refuses while
a process owned by the account is alive, so an implementation that trusts `terminate-user` works on a quiet
account and fails on a member who left a shell open, which is the normal case.
Required after terminating:
```
pkill -u <user> # wait, then re-check
pkill -9 -u <user> # only if the count is still non-zero
```
with a bounded wait between and a final assertion that the process count is zero. **Do not proceed while it is
not.**
### 3. Sever the data from the uid — *before* releasing it
Two policies. The platform's default is **preserve**:
```
chown -R <service-user>:<service-group> <member-tree>
```
Destroying a member's data because their account was deleted is a separate decision from removing their
access, and the platform has no standing to make it silently. Reassigning ownership severs the uid link while
keeping every byte.
**Destroy** is opt-in, for a deliberate rebuild:
```
rm -rf <member-tree>
```
**This step must complete before step 4.** That is the one ordering choice the manual teardown got wrong: it
released the uid first and removed the data afterwards, which leaves a window where the uid is free while
files still carry it. If the process dies in that window, the next `useradd` inherits them. Sever first, then
release — the irreversible step goes last, and only once nothing points at it.
### 4. Release the account
```
userdel <user> # NEVER -r
```
`-r` deletes the home, which contradicts the preserve policy and would make the destroy policy depend on a
flag rather than on an explicit decision. Measured: plain `userdel` removes the passwd, shadow and group
entries **and** the `/etc/subuid` and `/etc/subgid` ranges.
### 5. Verify, and refuse to call it done otherwise
See the checklist below. A deprovision that half-succeeded is worse than one that failed cleanly, because the
uid is free and something still owns files.
---
## Verification: what "clean" means
All of these must hold for the freed uid *and* its freed subuid range:
- `getent passwd <user>` → nothing
- no entry in `/etc/subuid` or `/etc/subgid`
- `find <DATA_PATH> /home -uid <uid>` → nothing
- `find <DATA_PATH> /home -uid <subuid-start> -o ... ` over the freed range → nothing
*(a range scan, not a single id — the mapped ids are spread across it)*
- `/var/lib/systemd/linger/<user>` absent
- `/run/user/<uid>` absent
- no processes owned by the uid
Worth extracting as `assertUidFree(uid, subuidRange)` and reusing it as the post-condition of the function and
as a test.
**Trap for the verifier:** do not use `sudo -u <user> …` to check anything after step 2. Creating a session
starts a user manager and recreates `/run/user/<uid>`, so the check would undo the step it is verifying.
---
## Behaviour requirements
**Idempotent.** Every step tolerates already-done. Re-running on a clean box is a no-op, and re-running after a
partial failure completes it. The delete handler should be able to call it, fail, and have an operator press
retry.
**Never throws; returns a result.** Same posture as `provisionOsAccount`, `provisionSshAccess` and
`provisionRootlessDocker`.
**But a failed deprovision is not the same as a failed provision.** An account that fails to provision is
merely unusable. An account that fails to *de*provision may have a freed uid with files still owned by it,
which is the hazard itself. So:
- if step 3 (sever) fails, **do not proceed to step 4**. Leaving the account intact is strictly safer than
freeing a uid that still owns data.
- a partial failure must be surfaced loudly, not warned into a log the way a missing Docker install is.
- the `users` row should not be considered fully deleted while the OS side is in a partial state, or the
platform forgets about a mess it created.
**Must not:** run `userdel -r`; delete data under the preserve policy; touch any account other than the one
named; run anything as the member after step 2.
---
## Call sites
- `deleteUserHandler` — the reason this exists.
- An admin-triggered retry, for an account left in a partial state.
- Worth considering: a startup reconciliation that reports Linux accounts with `os_user` set and no
corresponding `users` row. That is exactly how `officer_jg` would have been noticed months earlier, and it
is a report rather than an action — nothing should be deleted automatically at boot.
## Open questions for whoever implements it
1. **Where does severed data go?** Reassigned in place under the member's old path, or moved somewhere that
reads as archival? In place is simpler; a `deleted/` location makes it obvious the data is orphaned.
2. **Is destroy ever exposed in the UI**, or is it always a deliberate operator action outside the platform?
3. **Should uid allocation avoid reuse entirely** as defence in depth — a monotonic counter rather than
`useradd`'s lowest-free? The previous discussion concluded severing is better, and it is, because it also
fixes orphaned files. The two are not exclusive.
4. **What happens to a member's rootless Docker images and volumes** under preserve? They become unreadable to
any live account once chowned, which is correct but means the disk stays occupied by data nobody can open.
---
## Provenance
Every measured claim here comes from a real teardown on the production host on 2026-08-11: the surviving
account and container after a UI delete, the shell that outlived `terminate-user`, `userdel` releasing the
subuid ranges, and the final verified-clean state (no accounts ≥ 1000 but the owner, no files owned by 1001 or
1002 anywhere under `DATA_PATH` or `/home`, linger empty, the owner's eight containers untouched).
The one thing not measured is the preserve path. The teardown used `rm -rf`, because the data was a disposable
test database. `chown -R` as a severing mechanism is reasoned, not observed.
+445
View File
@@ -0,0 +1,445 @@
# Per-user Linux accounts
**Status: in progress.** Stage 1 (the account and the privilege-drop mechanism) is being built now.
Agents are explicitly out of scope for the first pass.
## What this is for
Today every `execution` capability — terminal, chat, files, tasks, items, desktop, browser — runs as the
**owner's OS user in the owner's home**. That is why `capabilities/registry.ts` declares them
`kind: 'execution'` and why `authorize.ts` strips them from a grant even if a row somehow contains one.
The registry says so out loud: *"revisit only if per-user home confinement is ever solved — and that is a
project, not a checkbox."*
This is that project. A member gets a real Linux account whose home is the directory the platform already
provisions for them, and the surfaces that execute code run **as that account**. The payoff is three
things at once:
- **Isolation** — a member cannot read another member's files, because the kernel says so rather than
because a path check happened to be right.
- **Permissions** — "may they see this" becomes a mode bit, checked by the OS on every syscall, instead
of a predicate the platform has to remember to apply on every route.
- **Separable agents** — `claude` and `opencode` run as the member, with their own `~/.claude`, their own
transcripts and their own session state, because the CLI groups by HOME and cwd.
## The target for the first test
A member signs in and:
- the **file browser** shows their home as the root and cannot navigate above it;
- the **terminal** lands in their home and has no permission to see anything above it.
Nothing else changes. Agents stay owner-only until this much is solid.
## The layout, and what each mode bit is for
```
data/ 711 service user traverse only — a member cannot enumerate the members
└── <email>/ 711 service user traverse only — a member cannot see their OWN siblings
├── home/ 700 the member their real Linux home
├── attachments/ 700 service user platform-written; unreachable even by name
├── email_accounts/ 700 service user "
├── dashboards/ 700 service user "
└── … 700 service user "
```
The important line is the second one. `data/<email>/` is traverse-only **to its own member**: they need
`x` to reach `home/`, and they must not have `r`, or they could list the platform's private tree beside
it. And because every sibling is `700 service user`, knowing a name does not help — traversal without
read gets you exactly one place, which is where they are going anyway.
This is also what resolves the two-sided ownership problem. The platform runs as the service user and
writes attachments, email databases and dashboards into `data/<email>/`; the member owns only `home/`.
Nobody needs a shared group, a setgid bit or an ACL, and neither side can write where the other lives.
**Members' homes stay under `DATA_PATH`** rather than moving to `/home/<user>`. They are the platform's
data, they belong with the rest of that account's data, and the directory is already provisioned there by
`provisionUserDirs`. A move would also break `getOwnerHomeDir`'s fallback, which is the only shape the
code has ever had for a non-owner home.
## Hard prerequisite: the secrets a shell can currently read
**This must be fixed before any member gets a shell, and it is not optional.**
On this machine, verified 2026-08-11:
| path | mode | consequence |
| --- | --- | --- |
| `/home/pastilhas` | 751 | traversable by anyone (no listing) |
| `…/officer.dev` | 775 | listable by anyone |
| `…/platform/.env` | **664** | **world-readable** |
`platform/.env` holds `POSTGRES_URL`, the JWT signing secret and every service credential. A member with
a real shell could read it and mint themselves an owner token, which makes the whole exercise worse than
not doing it — the capability model would be intact and completely bypassed.
So stage 1 includes: `chmod 600` on every `.env`, `chmod 751` on the project root so the tree is
traversable but not listable, and a **boot-time check that refuses to enable OS users while any `.env`
under the project root is group- or world-readable.** A prerequisite that is merely written down is a
prerequisite that gets skipped.
The same applies to `capabilities/` (775 today) and to the repo checkout itself: a member can read the
platform source. That is acceptable — it is not secret — but anything credential-shaped inside it is not.
## The mechanism, and the trap in it
### `Bun.spawn` silently ignores `uid` and `gid`
Verified on bun 1.3.10, 2026-08-11. From uid 1000:
```js
Bun.spawn(['id', '-u'], { uid: 65534, gid: 65534 }) // exit 0, prints "1000"
```
It does not throw. It does not warn. It accepts the option and runs as the parent. Every agent, task and
script spawn in this codebase goes through `Bun.spawn`.
Two honest qualifications, because the danger is narrower than it first looks:
- **Bun's own types do not declare `uid`**, so `bunx tsgo` rejects it. Typed code cannot reach this by
accident — confirmed while writing the test, which needs a cast to reproduce the behaviour at all.
- What *can* reach it is a spread of untyped config, an `as any`, or a plain-JS sidecar. Two of the four
sidecars are `.mjs`.
So the exposure is real but bounded, and the mitigation is the same either way: privilege drops go through
an external wrapper, and a test pins Bun's runtime behaviour. If Bun ever implements the option, that test
fails and tells us we may simplify. **A silently absent isolation boundary is the worst possible outcome of
this project**, so it is worth a test that exists only to observe something staying broken.
### `sudo -n setpriv`, and why both words are needed
`runAs` builds:
```
sudo -n setpriv --reuid=<user> --regid=<user> --init-groups --reset-env -- <argv…>
```
- `--reuid`/`--regid` set the real ids, not just effective — there is nothing to switch back to.
- `--init-groups` applies the account's supplementary groups. Without it the process keeps the *owner's*
groups, which is a quiet way to retain access we just took away.
- `--reset-env` clears the inherited environment and then sets `HOME`, `SHELL`, `USER`, `LOGNAME` and
`PATH` from the target's passwd entry. Both halves matter: the parent's env contains the owner's `HOME`,
and on a process started by PM2 in the platform directory it contains everything Bun auto-loaded from
`.env`.
**`sudo` is not optional, and the reason is not the uid.** Measured 2026-08-11: `--init-groups` fails with
`initgroups failed: Operation not permitted` for an unprivileged caller *even when reuid'ing to its own
account* — `setgroups(2)` is root-only, unconditionally. So there is no unprivileged form of this. `-n`
makes a missing sudoers entry an immediate error rather than a process hanging on a password prompt no
user will ever see.
Verified end to end, dropping to the current account:
```
$ sudo -n setpriv --reuid=pastilhas --regid=pastilhas --init-groups --reset-env -- \
sh -c 'id -u; id -G; echo HOME=$HOME; echo SECRET=${POSTGRES_URL:-unset}'
1000
1000 4 24 27 30 46 101 988 1001 ← supplementary groups from the account, not inherited
HOME=/home/pastilhas ← from passwd, after the reset
SECRET=unset ← the platform's .env did NOT cross
```
That last line is the whole security property, demonstrated rather than asserted, and it is pinned by a
test (`os-user.test.ts` → "does not pass the platform environment through").
`sudo -u <user>` alone would also work and be shorter. It is not used because its environment handling is
sudoers *policy*`env_reset`, `env_keep`, `always_set_home` — and "which variables cross into a member's
shell" must not depend on a config file someone may have edited.
Root is available: `scripts/setup.sh` §4 installs `/etc/sudoers.d/officer-service` granting the service
user `NOPASSWD: ALL` on the full profile. The light profile deliberately skips it, so a light install that
wants OS users needs a **narrow** entry — `useradd`, `chown`, `setpriv` — which is better than the blanket
rule anyway.
### The terminal is the easy one
`sidecar/pty/sessions.mjs` uses **node-pty** under **node**, and node-pty's `spawn` genuinely honours
`uid`/`gid` (it is a native binding, not Bun's spawn). Two options; we take the second:
1. Run the pty sidecar as root and pass `uid`/`gid` per session.
2. Keep the sidecar unprivileged and make the command `setpriv … <shell> -i`.
(2) means no root daemon and one mechanism shared with everything else. A root daemon accepting session
requests over a socket is a bigger promise than this feature needs to make.
## Naming
**The username the owner chose, verbatim.** `whoami` in a member's terminal says who they are, their
prompt is their name, and a commit from their edge checkout is attributed to something recognisable.
This carried an `officer_` prefix for about an hour. The prefix bought three things — no collision with a
system account, a greppable record of what the feature created, and a member unable to pick a name that
shadows something real — and cost the only thing anyone would notice. Measured before removing it:
`useradd` on this host accepts everything `validateUsername` already permits, including dots, hyphens,
underscores and uppercase.
**What replaced the prefix's safety is the adoption rule, and it had to.** `ensureOsUser` reuses an
existing Linux account, which is what makes it re-runnable. That was safe by construction while only we
created `officer_*` names. With the name being whatever was typed, adoption became the dangerous path: a
platform account named `root` would have found root in passwd, and every `runAs` for that member would
have been a root shell. So an existing account is adopted **only when its passwd home is already exactly
the home we are about to confine** — that is what makes it ours — and any uid below 1000 is refused
outright as belt and braces.
Verified:
```
username "root" -> refused: 'root' is a system account on this machine.
username "daemon" -> refused: 'daemon' is a system account on this machine.
the owner's own account -> refused: 'pastilhas' is already a user on this machine, with its
home at /home/pastilhas. Refusing to take it over.
```
The resolved name is **stored** on the user row (`users.os_user`) rather than re-derived. `useradd` can
adjust or refuse a name, and re-deriving would mean the platform's idea of who a member is could drift
from what is actually in `/etc/passwd`.
## The ancestor trap
A member's home sits under `DATA_PATH`, which on a normal install sits under the **owner's** home — and
`/home/<owner>` is `750` on Debian and Ubuntu. Every mode bit on the account tree can be correct, the
directory can exist, and the member still cannot reach it, because they have no `x` on an ancestor four
levels up.
What that surfaced as, on the first real install:
```
ssh-keygen failed: Could not stat …/data/jg@pertento.ai/home/.ssh: Permission denied
```
Which points at exactly the wrong thing. `.ssh` was there and correctly owned; the account could not
traverse `/home/pastilhas`.
`firstUntraversableAncestor` now walks the chain **as the member** before anything tries to use the home,
and the error names the directory and the fix (`chmod o+x <dir>`). `x` without `r` is the ask throughout:
traversal, not listing — nobody gains the ability to enumerate the owner's home.
The development machine happened to be `751` already, which is exactly why the probe passed there and
failed on a fresh install. Worth remembering as a shape of mistake: the probe used `/tmp`, so it never
crossed the ancestor that mattered.
## Out of scope, and honest about it
- **This is not a sandbox.** A member with a shell is on the machine. They cannot read the owner's files
or another member's, and `sudo` is not theirs — but they can run code, see process names, and reach the
network. It isolates members from each other and from accidents, not from the host.
- **Agents come later** — but by less than this said. Two claims here were wrong and are corrected on
2026-08-11; the superseded text is in the git history of this file, and the working state is
`COMMS/sidecar-app-store/2026-08-11-per-user-claude-handoff.md`.
It said the SDK "has nowhere to put a uid", so dropping privileges had to happen *outside* it, making a
member's turn its own process — "a change of shape rather than a flag". It is a flag: `sdk.d.ts:951`
exposes `spawnClaudeCodeProcess`, documented for running Claude Code "in VMs, containers, or remote
environments", and `node:child_process.spawn` already satisfies the `SpawnedProcess` shape it wants. So the
existing sidecar wraps the CLI spawn in `runAsArgv` per turn and there is no second process to stand up.
It also said the credential is not the problem because `officer-anthropic-proxy` already holds it, so a
member's `claude` "needs only `ANTHROPIC_BASE_URL` pointed at the proxy". That is backwards. The proxy holds
the **owner's** credential (`sidecar/claude/proxy.ts:7` reads the owner's own
`~/.claude/.credentials.json`), so pointing a member at it spends the owner's account on the member's
turns. Per-user Claude means their own login in their own home, and `setpriv --reset-env` is what makes that
the default rather than something to remember: nothing crosses into their process unless it is written into
the argv.
What does remain out of scope: **no platform process ever runs as a member.** The agent sidecar needs
`POSTGRES_URL` and the JWT signing secret, so a member-uid process holding them could read every account and
sign a token as the owner — more than their shell can do, and already refused by `assertSecretsClosed`. The
harness stays the service user's; only `claude` itself drops privileges.
- **`pty`, `vault` and `opencode` receive no identity at all** (`TODO.md` → Multi-user). pty keys purely
on a `sessionId` from the query string, and its `/_officer/sessions` endpoints list and kill *every*
session on the box. Safe today only because terminal is owner-only. **The moment a member has a shell
that is a cross-user kill switch**, so it is fixed in the same stage as the terminal, not after.
- **Email change orphans a home.** The on-disk layout is keyed on email everywhere. Renaming an account
would leave its home behind under the old address. Pre-existing, unfixed, worth knowing.
## What the first real run proved, and what it corrected
Stage 1 was exercised end to end against a throwaway `DATA_PATH` with a real `useradd`. Every property
below was **observed**, not reasoned about:
| attempted, as the member | result |
| --- | --- |
| write in own home | OK |
| read `…/<email>/attachments/private.txt` | Permission denied |
| `ls …/<email>/` (their own account dir) | Permission denied |
| `ls $DATA_PATH` (enumerate the members) | Permission denied |
| `ls …/other-member@example.com/home` | Permission denied |
| `cd $HOME/..` | **succeeds** — see below |
Three bugs surfaced only by running it:
1. **`chmod` after `chown` fails forever.** `chmod` requires ownership, so once the home belongs to the
member the service user cannot set its mode. Both orderings fail unprivileged — the first on the second
run, the second immediately. Both operations now go through sudo, which is what makes the function
re-runnable.
2. **A member could read another member's home.** `provisionUserDirs` created directories at the default
umask (`755`), and the confinement pass only ever ran for the account being created. `DATA_PATH` being
unlistable is not protection when the child is world-readable and the attacker knows an email address.
The skeleton is now created closed — `711` on the account directory, `700` inside — so *unconfined* is
also *unreachable*.
3. **`platform/.env` was readable, and printing `JWT_SECRET` from a member's shell was confirmed.** This is
the prerequisite above, demonstrated. It is now a boot check (`assertSecretsClosed`) that refuses to
start with `OFFICER_OS_USERS` on while any `.env` in the project root is group- or world-readable.
**`cd $HOME/..` succeeding is correct and worth being precise about.** `711` grants traversal, so `cd`
works while `ls` does not — they can stand in the directory and see nothing in it. Beyond that, a real
shell can reach `/etc`, `/usr` and anything else the system leaves world-readable, because that is what a
shell is. So:
- the **file browser** genuinely cannot go above the home — that is path containment in `resolveUserPath`,
enforced by the platform;
- the **terminal** cannot *read* anything above the home, but is not confined to it. Confining it would
mean a namespace or a chroot, which is a different and much larger feature.
Say "cannot see behind it", not "cannot leave it".
## SSH: two keys, two directions
A member is meant to behave like a real user on the machine — reachable over SSH, able to push to Gitea as
themselves, able to have an agent do the same on their behalf. That needs two keys, and they are **not**
alternatives:
| | where | who holds the private half | what it is for |
| --- | --- | --- | --- |
| **inbound** | `~/.ssh/authorized_keys` | the member, on their laptop | *they* SSH into this machine |
| **outbound** | `~/.ssh/id_ed25519` | this machine, generated here | *the machine* authenticates to Gitea as them |
The tempting simplification is "if they pasted a key, skip generating one." It breaks the actual goal.
Agent forwarding covers a human in an interactive session; a **platform-spawned agent has no agent socket
to borrow**, so an edge checkout it is asked to commit and push needs a key that lives on the box. So the
inbound key is optional — an account without one is simply platform-only — and the outbound keypair is
generated regardless.
**No Linux password, ever.** `useradd` is called with none, which leaves `!` in shadow. That blocks
*password* login and does **not** block key auth, so "real user, reachable over SSH, no password anywhere"
is the resting state. The privilege drop is `sudo -n setpriv` performed by the platform, so there is nothing
to authenticate. Keeping the platform password and the machine out of each other's business is the point: a
Linux password would be a second door that changing the platform password does not close and deleting the
platform account does not lock.
**Validation is about line count, not key shape.** Every line of `authorized_keys` is a credential, so a
pasted value containing a newline would silently install a *second* authorized key. `validatePublicKey`
refuses anything multi-line, refuses a private key with a message saying so, and refuses an options prefix
(`command="…" ssh-ed25519 …`) — legitimate OpenSSH, but not something anyone pastes by accident, and it can
force a command.
**Everything is written with `sudo install`.** The home is 700 and owned by the member, so the service user
cannot create `.ssh` at all. `install` sets content, owner and mode in one step, which also closes the
window where a key file briefly exists at the process umask. File content goes via a temp path rather than
shell text, so nothing has to reason about quoting a value that came from a form.
**`StrictHostKeyChecking accept-new`, not a seeded `known_hosts`.** The Gitea SSH endpoint is not knowable
at account-creation time — the platform stores an HTTP base URL, and SSH may be a different host or port.
The failure this avoids is specific: the default setting makes a first connection *prompt*, and a prompt in
a non-interactive agent turn is a hang, not an error. `accept-new` trusts on first use and still refuses a
*changed* host key, which is the attack that matters.
**The generated public key is stored on the user row** (`users.os_ssh_public_key`) and shown after creation
and on the user's row afterwards. It is public by definition, and it has an errand attached that nothing
else will remind anyone about: it has to be added to that person's Gitea account or their pushes fail with
a permission error that says nothing about a missing key.
Verified end to end with a real `useradd`: `.ssh` 700 and `id_ed25519` 600 both owned by the member and
readable by them, `authorized_keys` byte-identical to what was pasted, the key **not** rotated on a second
run (it has been added to Gitea by then), and a multi-line paste refused with `authorized_keys` left
untouched.
## Docker: rootless, one daemon per member
Verified working on a real member account, 2026-08-11.
**Not the `docker` group.** `usermod -aG docker <user>` is the one-line version and it is root: membership
means talking to the host daemon, which runs as root, so `docker run -v /:/host -it alpine chroot /host` is
a root shell. That reads `.env`, every other member's home and the wallet seed — every boundary above,
bypassed by one documented command. The group is not "access to Docker", it is "root, by a longer route".
Rootless gives what was actually wanted: a daemon per account, containers in that account's user namespace,
images under their own home. Measured — the daemon runs as the member, `docker pull` put 403 MB in their
home, and `docker ps -a` showed nothing while the owner had four containers running.
**Host prerequisites**, all in `setup.sh` as core packages: `uidmap` (newuidmap/newgidmap — rootless cannot
start without them), `dbus-user-session`, and Docker's own rootless extras. `useradd` allocates the
`/etc/subuid` range automatically wherever `login.defs` sets `SUB_UID_COUNT`, and `userdel` reclaims it.
**`loginctl enable-linger` is required, not optional.** Officer's shells are not login sessions, so without
it a member's daemon would stop the moment their terminal closed.
**The setup tool's exit code is not the gate.** It writes `~/.config/systemd/user/docker.service` and then
fails its own `systemctl --user start` with "Unit docker.service not found", because nothing reloaded a
manager that was already running. So: run it, `daemon-reload`, start it ourselves, and verify by asking the
daemon its version.
**Two features built the same day collided.** Creating a volume copies xattrs, and the DEFAULT ACLs on a
member's home — added so the file browser could read their files — are inherited by Docker's storage, where
a mapped id inside a user namespace is not a valid id to set:
```
failed to copy xattrs: failed to set xattr "system.posix_acl_default" on …/volumes/…/_data: invalid argument
```
Every container failed to start while the image pulled perfectly. The fix strips DEFAULT ACLs from
`~/.local/share/docker` only (`setfacl -R -k`), leaving the access ACLs the file browser depends on. Losing
the platform's reach into Docker's internal storage costs nothing: it is layers and volume data, read
through `docker` or not at all.
### The port space is shared, and that is not fixed
A rootless daemon is isolated; the **host's port space is not**. RootlessKit publishes into it, so a member
mapping `5432` collides with the owner's production Postgres — observed immediately:
```
error while calling RootlessKit PortManager.AddPort(): listen tcp4 0.0.0.0:5432: bind: address already in use
```
Two consequences worth knowing:
- **Publish on `127.0.0.1` explicitly.** A bare `-p 15432:5432` binds `0.0.0.0` in rootless mode, putting a
member's dev database on the network. `127.0.0.1:15432:5432` is all they need to reach it from their own
shell.
- **Nothing allocates ports.** With one member the owner manages it by hand, which is where this stands
deliberately. With several, a per-member offset is the crude answer that works.
## Follow-ups this creates
- **Deleting a member no longer removes their home.** It belongs to their uid, so the platform cannot
remove it — `rm -rf` fails with EPERM, which is how it was noticed. `deleteUserHandler` does not touch
disk today so nothing is broken, but account deletion will need `userdel` and a sudo `rm` to stop
leaving an orphaned, unremovable directory behind.
- **`confineUserTree` sets `DATA_PATH` itself to 711.** If a container ever bind-mounts a path under
`DATA_PATH` and runs as another uid, it will traverse but not list.
**This happened, and it was worse than the note predicted.** Reported from a live server: a bind-mounted
`postgres:18-alpine` crash-looped with `mkdir: can't create directory '…/18/docker'` on a directory that
already existed. Two reasons the prediction was too mild. The image's inner uid is 70, which maps through
the member's subuid range to 231141 — neither the service user nor the member, so `other`. And by then the
home carried `default:other::---` from the ACL work, so `other` had lost even the traverse bit that the 711
reasoning assumed. Traverse-but-not-list became no-traverse-at-all.
`3bea46f`'s fix — stripping defaults from `~/.local/share/docker` — covered NAMED VOLUMES only. A bind
source lives wherever the member put it. A named volume passes with the bug present, which is exactly why
that fix looked complete.
Now: `~/.local/dockers` is provisioned at `711` with **all** ACLs removed (`setfacl -R -b`, not `-k`), and
is the documented place for compose bind mounts. `711` rather than `700` is the point — a container's inner
uid needs `x` to reach a bind source inside, and no ACL can grant what the mode denies. `-b` rather than
`-k` because `-k` left `mask::---` behind, so inherited named entries read as `rwx #effective:---`: an ACL
that says one thing and means another.
Bounded deliberately. A member bind-mounting from elsewhere in their home still hits the denial; this is
the place that works, not a guarantee about everywhere. The alternatives were worse — extending the strip
cannot work when the member chooses the path, and `d:other::--x` on the whole home loosens every directory
forever to fix one local case.
## Stages
1. **The account and the mechanism.** `users.os_user`; `ensureOsUser` (useradd + chown + the mode bits
above); `runAs`; the `.env` permission gate; tests including the Bun-ignores-uid pin. **No behaviour
change** — accounts are created and nothing uses them yet.
2. **`getOwnerHomeDir` honours its email argument.** It takes an email and throws it away whenever
`HOME_DIR` is set, which is always on a real install. Seven call sites; this one change repoints the
file browser, chat, tasks, agents and the VNC password file per account.
3. **The file browser**, rooted at the member's home. Containment already exists — `resolveUserPath` +
`isInside`, which has the `..`-escape fix in it — so this is a root-resolution change, not new
security code.
4. **The terminal**, via `setpriv`, plus pty identity. One `execution` capability reopened.
5. **Agents.** Separately, later, with the SDK problem solved first.
+166 -11
View File
@@ -60,8 +60,11 @@ for someone who does not. The prompt is the fork.
**Officer is the installer, never the owner.** Concretely: **Officer is the installer, never the owner.** Concretely:
- A real `docker-compose.yml` per service, written into a **user-owned directory** - A real compose file per service, written into **`<root>/dockers/<id>/`**, from our template — using
(`~/officer-services/<service>/`), from our template. the convention the owner already applies to 47 services: one directory per service,
`docker-compose.yaml` inside, and **relative bind mounts** (`./data`, `./database`, `./storage`) so
configuration and data sit beside the compose file where both we and a human can see them. Named
volumes are used by 3 of those 47 and are the exception; templates use bind mounts, always.
- Started with `docker compose up -d` **as the owner**, not as officer's own identity. - Started with `docker compose up -d` **as the owner**, not as officer's own identity.
- Found again by **label** (`officer.sidecar=<id>`), not by holding a handle. - Found again by **label** (`officer.sidecar=<id>`), not by holding a handle.
@@ -75,8 +78,58 @@ Consequences, which are the point:
The template is what makes this non-technical-user-friendly: sensible defaults, ports, volumes and The template is what makes this non-technical-user-friendly: sensible defaults, ports, volumes and
health checks already correct, so "install Gitea" does not become a tutorial. health checks already correct, so "install Gitea" does not become a tutorial.
`[open]` Rootful Docker runs container processes as root unless `user:` is set. Do we set it? And do we **`USER_UID` / `USER_GID` are set to the owner**, as the existing services already do. That answers the
support Podman for people who want genuinely rootless? "do containers run as root" question: no, and this is not a new convention — it is the one in use.
**The app store's containers are isolated from the user's own**, and that is the point of the layout:
```
~/officerdev/
platform/ the app
data/ DATA_PATH
dockers/ services the app store provisioned <- exclusively ours
capabilities/ the file-based item store
```
`OFFICER_ROOT` is derived as the parent of `DATA_PATH` rather than configured separately — a second
variable that must agree with the first is a second thing to get wrong.
Deliberately **not** `~/dockers`, which is where a seasoned user already keeps their estate. Two
consequences, both wanted:
1. Containers the app store created are distinguishable from the user's own **structurally**, not by a
naming convention we would have to enforce and they could break.
2. **We never reason about someone else's compose files.** The store does not scan, adopt or modify
anything outside its own directory. "I already have one of these" is answered by the user giving a
URL (`mode: 'existing'`) — never by us finding a directory and guessing whose it is.
`[open]` Podman, for anyone wanting genuinely rootless.
### Docker is assumed, and nothing guarantees it
Verified: **nothing in `scripts/` installs Docker, and nothing checks for it.** `setup.sh` calls
`setup-dockers.sh`, which invokes `docker compose` with no preflight, so a fresh host without Docker
fails partway through setup with a bare "command not found".
That is the seam where this project's origin shows — it began as one person's own machine, provisioned
by his own scripts, where Docker was simply always there.
The intended fix is **a `setup.sh` per sidecar**, ensuring its own dependencies before its compose file
is used. That is also the shape a sidecar needs once it lives in its own repository, so a sidecar package
becomes:
```
metadata (catalogue entry) · compose template · setup.sh · schema
```
Until that exists, the app store **detects and reports** rather than guessing or half-installing:
`preflight.ts` checks `docker compose version` — which exercises the binary, the daemon connection and
the plugin in one call, unlike `docker --version`, which passes with a dead daemon — and distinguishes
"not installed" from "daemon unreachable", because the remedies differ.
The check is **per mode, not per entry**: a host without Docker can still install Photos by pointing at
an Immich elsewhere. Refusing the whole entry would be the over-strict check that makes people work
around the installer instead of using it.
--- ---
@@ -87,15 +140,32 @@ Two independent flags, because they answer different questions:
- **`installed`** — the thing exists: container provisioned, config written, schema applied. - **`installed`** — the thing exists: container provisioned, config written, schema applied.
- **`enabled`** — the process should be running. - **`enabled`** — the process should be running.
That yields the three outcomes asked for: | Action | Sidecar process | Container | Data & schema |
| ------------- | --------------- | ------------------------------ | ------------- |
| **Disable** | stopped | stopped | untouched |
| **Enable** | started | started | untouched |
| **Uninstall** | stopped | `docker compose down`, removed | untouched |
| Action | Effect | Disable stops the container too — there is no reason to leave Immich holding memory while Photos is
| ------------------------ | ------------------------------------------------------------------------------------ | switched off. For `mode: 'existing'` there is no container of ours, so disable is only the sidecar.
| **Disable** | Stop the sidecar. Container, config, schema and data all stay. Re-enable is instant. |
| **Uninstall, keep data** | Stop, remove the process. Leave container volumes and rows. |
| **Full uninstall** | Also `docker compose down -v` and drop the sidecar's tables. |
The middle one is the in-between; the user chooses disposal at uninstall time rather than us guessing. Uninstall additionally deletes the `sidecar_installs` row. It does **not** drop the sidecar's tables.
**Nothing above deletes data, and there is no option that does.**
### Why the schema survives uninstall too
Dropping a sidecar's tables is deleting data. Not media, but real: music favourites, the Jellyfin server
registry, photos configuration, saved connections. That is the same category as volumes and gets the
same answer.
It also buys something. **Reinstall becomes restore** — uninstall Photos in June, reinstall in August,
and the configuration and favourites are still there. Drop the schema and reinstalling hands back a
blank service that looks subtly broken to someone who remembers setting it up.
Keeping them costs nothing: an unused table is a row in `information_schema`. No queries, no memory, no
maintenance. Dropping them joins volume deletion in the later, deliberate cleanup feature, where the
user sees what they are removing.
**Install must be idempotent and resumable.** Provision → health → config → schema → start is five steps **Install must be idempotent and resumable.** Provision → health → config → schema → start is five steps
and any of them can fail. The failure mode to design against is a half-installed service that neither and any of them can fail. The failure mode to design against is a half-installed service that neither
@@ -174,6 +244,91 @@ What a plugin author is promised, and bound by. To be written properly; the shap
--- ---
## Provisioning has three shapes, not one
This document originally said provisioning "writes the connection we already know". That is only true
some of the time, and the difference decides whether an install can finish unattended:
1. **We set the credentials.** Passed as container environment, so the connection is known the moment it
is up. Transmission (`USER`/`PASS`), Vaultwarden (`ADMIN_TOKEN`).
2. **We generate a secret into a file.** The bind mount lets us write it before first boot, so it is
still known without asking. slskd's API key lives in its `slskd.yml`.
3. **A human must mint a token in the service's own UI after it boots.** Immich, Jellyfin and Memos all
work this way — no environment variable pre-seeds an API key.
Shape 3 means an install can be **provisioned and running but not yet connected**. That is a real state,
not a failure: the container is up, the compose file is written, and we are waiting for a token. The
step machine stops there, and the UI asks for the key with a link to the page that mints it. Resuming
finishes the job — which is what `completedSteps` was for.
---
## Members get their own accounts
The owner installs, but a server may already have members — and a member added next month needs the same
work done. So the unit is **(service × member)**, reachable from two triggers:
```
install a service -> provision every member who already exists
add a member -> provision every service already installed
```
Only handling the first is the classic thing that works on day one and rots quietly. There is no new
table: a member is provisioned for a service exactly when they hold a `service_connections` row for it —
their own credential, `url` NULL, inheriting the instance from the owner's. That schema was built for
this before this existed.
Three outcomes, declared per catalogue entry as `members`, so the installer never special-cases a
service:
| | Meaning | Services |
| ---------- | ------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------- |
| `accounts` | Admin API creates the user **and** mints a credential. Fully transparent — the member just finds it working. | Immich, Jellyfin, Memos, InvoiceShelf, CalDAV |
| `invite` | The account can be created; a usable credential cannot. The member sets their own password. | Vaultwarden |
| `none` | Single-tenant daemon, no user concept. Access is mediated by Officer alone. | Transmission, slskd, headscale, email, music, wallet, notify, vnc |
**`invite` is not a weaker `accounts`** — it is the correct outcome. Vaultwarden derives its encryption
key from the master password, so a credential we could mint would mean a vault we could read. Transparent
right up to the point where being transparent would be a defect.
The per-service work is an **interface implemented beside each sidecar**, never a switch in core: a
central function growing one case per service is exactly what would stop any of this shipping from its
own repository. Implementations must be idempotent — both triggers can fire for the same pair, and
creating a second account upstream is not something we can undo.
Deprovision is deliberately optional and defaults to doing nothing upstream. Deleting a user in Immich
deletes their photos; an app store that destroys data as a side effect of an unrelated action is worse
than one that leaves a stale account behind.
**Assumed working:** the vault's own multi-user adaptation is being done separately. Today `/api/vault`
is owner-only by an explicit `ownerGate`, so a member is refused before Vaultwarden is reached — this
design is written as though that has landed.
---
## What Phase 0 must not foreclose
Three things are coming, and each one constrains a decision that looks free today.
**1. `marketplace.officer.dev`.** Phase 1 keeps the catalogue inside this repo; later the app lists what
is on a remote marketplace instead. So catalogue entries must stay **serialisable data** — no functions,
no imports, nothing that only means something at compile time. They are plain objects today and must
remain so, because the same shape has to arrive as JSON over HTTP. Compose templates travel with them.
**2. Every sidecar becomes its own repository.** Today `catalogue.test.ts` asserts the catalogue equals
"everything in ecosystem.config.cjs that light excludes". That is the right check _now_, and it inverts
later: once sidecars live elsewhere, the catalogue entry becomes the source of truth for how to run one
(command, args, env) and the ecosystem file is generated from what is installed, not the other way
round. **Do not treat that test as a permanent law** — it pins Phase 0's invariant, not the design's.
**3. Third-party plugins.** Already the reason per-sidecar schema is in scope. It is also why the
`service_connections` ID needs namespacing before the marketplace opens, not after.
The through-line: **nothing in Phase 0 may assume the catalogue is compiled in, or that a sidecar's code
is in this repository.**
---
## Open questions ## Open questions
1. `user:` in compose, and Podman support for rootless. 1. `user:` in compose, and Podman support for rootless.
+6 -5
View File
@@ -26,16 +26,17 @@ module.exports = defineProfile({
'officer-agent', // spawns `claude` — chat is dead without it 'officer-agent', // spawns `claude` — chat is dead without it
'officer-opencode', // the alternative agent 'officer-opencode', // the alternative agent
'officer-pty', // the terminal 'officer-pty', // the terminal
// Included even though a light host runs no Gitea: this sidecar fronts a REMOTE instance. Its URL
// and token live in `service_connections`, set from /gitea, so it needs nothing installed here.
// That is what separates it from the sidecars below, which supervise a local daemon or container.
// Same reasoning as the mac light profile, which has included it since it was added.
'officer-gitea',
], ],
// Excluded by CHOICE rather than by platform limits — every one of these would run on a Linux host. // Excluded by CHOICE rather than by platform limits — every one of these would run on a Linux host.
// A light install simply is not running the thing behind it. // A light install simply is not running the thing behind it.
excluded: { excluded: {
// Was in the baseline until 2026-08-11, on the reasoning that it fronts a REMOTE instance and so needs
// nothing installed locally. True, and beside the point: a baseline process appears in the Permissions
// screen and the dock whether or not anyone has given it a URL, so a fresh server offered to grant Gitea
// access to an instance that did not exist. It is installable now — `existing` mode, URL and token — which
// makes "is Gitea here" one question with one answer instead of two that disagree.
'officer-gitea': 'fronts a remote instance; installed from the app store with its URL and token',
'officer-vnc': 'no desktop to mirror on a light install', 'officer-vnc': 'no desktop to mirror on a light install',
'officer-email': 'needs the mbsync/IMAP stack the light profile does not install', 'officer-email': 'needs the mbsync/IMAP stack the light profile does not install',
'officer-music': 'the ffprobe indexer works, but a full library index is not a light-install concern', 'officer-music': 'the ffprobe indexer works, but a full library index is not a light-install concern',
+6 -5
View File
@@ -32,17 +32,18 @@ module.exports = defineProfile({
// The terminal. Runs under node rather than bun — node-pty binds a native addon built against // The terminal. Runs under node rather than bun — node-pty binds a native addon built against
// node's ABI. That detail lives in ecosystem.config.cjs, not here. // node's ABI. That detail lives in ecosystem.config.cjs, not here.
'officer-pty', 'officer-pty',
// Included even though a laptop hosts no Gitea: this sidecar fronts a REMOTE instance. The URL and
// its token live in `service_connections`, set from /gitea, so it points at gitea.pastilhas.dev
// over the network and needs nothing installed here. That is what separates it from the sidecars
// below that supervise a local daemon or container.
'officer-gitea',
], ],
excluded: { excluded: {
// Cannot run on macOS at all. // Cannot run on macOS at all.
'officer-vnc': 'mirrors an Xorg display with x11vnc; macOS has no Xorg', 'officer-vnc': 'mirrors an Xorg display with x11vnc; macOS has no Xorg',
// Left the baseline on 2026-08-11, on both light profiles together. It genuinely needs nothing installed
// locally — it points at a remote instance over the network — but a baseline process shows up in the dock
// and the Permissions screen whether or not a URL was ever given, so "is Gitea here" had two answers. It
// is an app-store install now: `existing` mode, URL and token, same as any other remote service.
'officer-gitea': 'fronts a remote instance; installed from the app store with its URL and token',
// Would run, but needs something setup_mac_light.sh deliberately does not install. // Would run, but needs something setup_mac_light.sh deliberately does not install.
'officer-email': 'needs the mbsync/IMAP stack setup_mac_light.sh does not install', 'officer-email': 'needs the mbsync/IMAP stack setup_mac_light.sh does not install',
'officer-caldav': 'supervises Radicale, which setup_mac_light.sh does not install', 'officer-caldav': 'supervises Radicale, which setup_mac_light.sh does not install',
+4 -20
View File
@@ -16,26 +16,10 @@
import { mkdirSync, existsSync } from 'node:fs'; import { mkdirSync, existsSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
// The list and DATA_PATH itself come from the platform rather than being restated here. The owner's
const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data'); // create-account handler provisions the same skeleton, and a script that drifted from it would produce
// accounts that differ by how they were made. Importing data-path.ts pulls in no database and no server.
// Mirrors the shape the owner's root grew organically. Most of these are also created on demand by import { DATA_PATH, USER_DIRS } from '../src/servers/data-path';
// whichever feature owns them (attachments, dashboards, email_accounts…), so pre-creating them buys
// legibility more than function — the tree shows what a user has without having to use it first.
//
// `home` is the exception, and the reason this exists at all: nothing creates it today. getOwnerHomeDir
// returns process.env.HOME_DIR whenever it is set, which it always is on a real install, so the
// per-user home has never actually been reached. It is where a non-owner's sessions will run.
const USER_DIRS = [
'home',
'attachments',
'cache',
'dashboards',
'email_accounts',
'general_chat_sessions',
'logs',
'sidecar',
] as const;
const DRY_RUN = process.env.DRY_RUN === '1'; const DRY_RUN = process.env.DRY_RUN === '1';
const emails = process.argv.slice(2).filter(Boolean); const emails = process.argv.slice(2).filter(Boolean);
+73 -27
View File
@@ -14,7 +14,7 @@
# Postgres, pm2 and the two agent CLIs, then starts ecosystem.light.config.cjs. # Postgres, pm2 and the two agent CLIs, then starts ecosystem.light.config.cjs.
# #
# Skipped by `light`: archive extras, the sudoers entry and auto-suspend disabling, Go, Rust, # Skipped by `light`: archive extras, the sudoers entry and auto-suspend disabling, Go, Rust,
# PulseAudio, cliamp, Neovim, the shell tooling (starship/oh-my-zsh/eza/lazygit), yt-dlp, and # PulseAudio, cliamp, Neovim, the shell extras (oh-my-zsh/eza/lazygit), yt-dlp, and
# the remote desktop. Of the Docker services only Postgres is brought up. # the remote desktop. Of the Docker services only Postgres is brought up.
# #
# The app itself is identical — every API route stays mounted, so the features whose sidecars # The app itself is identical — every API route stays mounted, so the features whose sidecars
@@ -230,6 +230,37 @@ case $PM in
;; ;;
esac esac
# uidmap — newuidmap/newgidmap, needed for a member's own rootless Docker.
#
# Rootless containers map subordinate uid ranges, and those two setuid helpers are the only way to do it
# unprivileged. Without them `dockerd-rootless-setuptool.sh` fails at the first step. Core rather than a
# profile extra for the same reason acl is: the alternative to a member having their own daemon is adding
# them to the `docker` group, which is root on the host — see src/servers/os-user-docker.ts.
case $PM in
apt)
if dpkg -s uidmap &>/dev/null 2>&1; then skip "uidmap"; else CORE_PKGS+=(uidmap); fi
if dpkg -s dbus-user-session &>/dev/null 2>&1; then skip "dbus-user-session"; else CORE_PKGS+=(dbus-user-session); fi
;;
pacman)
if has newuidmap; then skip "uidmap (shadow)"; else CORE_PKGS+=(shadow); fi
;;
esac
# acl — setfacl/getfacl, needed by per-user Linux accounts.
#
# A member's home is 700 and owned by them, which is right for a shell and locks the platform out of the
# file browser. Named ACL entries are what let both act on the same files without opening the home to every
# account on the box; mode bits cannot express it in both directions. Core rather than a profile extra
# because the alternative is an account that provisions and then cannot list its own home.
case $PM in
apt)
if dpkg -s acl &>/dev/null 2>&1; then skip "acl"; else CORE_PKGS+=(acl); fi
;;
pacman|dnf|yum)
if has setfacl; then skip "acl"; else CORE_PKGS+=(acl); fi
;;
esac
if [ ${#CORE_PKGS[@]} -gt 0 ]; then if [ ${#CORE_PKGS[@]} -gt 0 ]; then
install_pkg "${CORE_PKGS[@]}" install_pkg "${CORE_PKGS[@]}"
ok "Installed: ${CORE_PKGS[*]}" ok "Installed: ${CORE_PKGS[*]}"
@@ -476,13 +507,50 @@ else
skip "bun symlink at /usr/local/bin/bun" skip "bun symlink at /usr/local/bin/bun"
fi fi
# ─── 6b. Starship prompt ──────────────────────────────────────────────────────
#
# Outside the light-profile skip below, unlike the rest of the terminal tooling. The light profile exists to
# serve a file browser, a terminal and chat — the terminal is one of its three reasons to be, and it is also
# what every member gets when per-user Linux accounts are on. `src/servers/shell-skel/zshrc` deploys this same
# prompt to every account, so leaving starship out of light meant every member's shell fell back to the plain
# one on exactly the installs most likely to have members.
#
# One static binary and one config file. oh-my-zsh, eza and lazygit stay in section 12, where `light` skips
# them: those are host comforts, and the shell template treats each as optional.
echo ""
echo "── Prompt (starship) ──"
# Starship prompt
if has starship; then
skip "starship"
else
curl -fsSL https://starship.rs/install.sh | sh -s -- -y -b /usr/local/bin
if has starship; then ok "starship installed"; else warn "starship install failed"; fi
fi
# Deploy starship config. Unconditionally cp'ing here overwrote a customised ~/.config/starship.toml on
# every run, silently — the nvim step below already gets this right by guarding on the config's
# existence, so this was just inconsistent. Converge when there is nothing to lose, keep what the user
# wrote when there is.
mkdir -p "$HOME/.config"
STARSHIP_DEST="$HOME/.config/starship.toml"
if [ ! -f "$STARSHIP_DEST" ]; then
cp "$SCRIPT_DIR/starship.toml" "$STARSHIP_DEST"
ok "starship config deployed"
elif cmp -s "$SCRIPT_DIR/starship.toml" "$STARSHIP_DEST"; then
skip "starship config"
else
warn "starship config kept — yours differs (cp scripts/starship.toml ~/.config/ to take this one)"
fi
# Sections 7-13 are one block because `light` skips all of them. Go and PulseAudio exist to build and # Sections 7-13 are one block because `light` skips all of them. Go and PulseAudio exist to build and
# feed cliamp; Rust has no consumer left in the tree; Neovim, the shell tooling and yt-dlp are host # feed cliamp; Rust has no consumer left in the tree; Neovim, the shell tooling and yt-dlp are host
# comforts and capability dependencies rather than anything the app needs to serve a file browser, a # comforts and capability dependencies rather than anything the app needs to serve a file browser, a
# terminal and a chat. # terminal and a chat.
if is_light; then if is_light; then
echo "" echo ""
omit "Go, Rust, PulseAudio, cliamp, Neovim, shell tooling (starship/oh-my-zsh/eza/lazygit), yt-dlp" omit "Go, Rust, PulseAudio, cliamp, Neovim, shell extras (oh-my-zsh/eza/lazygit), yt-dlp"
else else
# ─── 7. Go ───────────────────────────────────────────────────────────────────── # ─── 7. Go ─────────────────────────────────────────────────────────────────────
@@ -669,32 +737,10 @@ else
ok "LazyVim starter installed at ~/.config/nvim" ok "LazyVim starter installed at ~/.config/nvim"
fi fi
# ─── 12. Terminal tools ────────────────────────────────────────────────────── # ─── 12. Terminal tools (starship is section 6b, outside the light skip) ─────
echo "" echo ""
echo "── Terminal tools (starship, oh-my-zsh, eza, lazygit) ──" echo "── Terminal tools (oh-my-zsh, eza, lazygit) ──"
# Starship prompt
if has starship; then
skip "starship"
else
curl -fsSL https://starship.rs/install.sh | sh -s -- -y -b /usr/local/bin
if has starship; then ok "starship installed"; else warn "starship install failed"; fi
fi
# Deploy starship config. Unconditionally cp'ing here overwrote a customised ~/.config/starship.toml on
# every run, silently — the nvim step below already gets this right by guarding on the config's
# existence, so this was just inconsistent. Converge when there is nothing to lose, keep what the user
# wrote when there is.
mkdir -p "$HOME/.config"
STARSHIP_DEST="$HOME/.config/starship.toml"
if [ ! -f "$STARSHIP_DEST" ]; then
cp "$SCRIPT_DIR/starship.toml" "$STARSHIP_DEST"
ok "starship config deployed"
elif cmp -s "$SCRIPT_DIR/starship.toml" "$STARSHIP_DEST"; then
skip "starship config"
else
warn "starship config kept — yours differs (cp scripts/starship.toml ~/.config/ to take this one)"
fi
# Oh-My-Zsh # Oh-My-Zsh
if [ -d "$HOME/.oh-my-zsh" ]; then if [ -d "$HOME/.oh-my-zsh" ]; then
@@ -809,7 +855,7 @@ else
skip "claude (claude-code)" skip "claude (claude-code)"
else else
echo " Installing claude-code via Anthropic installer..." echo " Installing claude-code via Anthropic installer..."
curl -fsSL https://claude.ai/install.sh | sh curl -fsSL https://claude.ai/install.sh | bash # bash, not sh: a piped script ignores its shebang and install.sh is bash
if has claude; then ok "claude-code installed"; else warn "claude-code install failed"; fi if has claude; then ok "claude-code installed"; else warn "claude-code install failed"; fi
fi fi
+1 -2
View File
@@ -53,8 +53,6 @@ export function App() {
<Route path="/chat/new/g/*" element={<Dashboard.SessionListPage isNew />} /> <Route path="/chat/new/g/*" element={<Dashboard.SessionListPage isNew />} />
<Route path="/chat/g/*" element={<Dashboard.SessionListPage />} /> <Route path="/chat/g/*" element={<Dashboard.SessionListPage />} />
<Route path="/chat/:sessionId" element={<Dashboard.SessionListPage />} /> <Route path="/chat/:sessionId" element={<Dashboard.SessionListPage />} />
<Route path="/plans" element={<Dashboard.Plans />} />
<Route path="/plans/:name" element={<Dashboard.Plans />} />
<Route path="/files" element={<Dashboard.FilesScreen />} /> <Route path="/files" element={<Dashboard.FilesScreen />} />
<Route path="/calendar" element={<Dashboard.CalendarScreen />} /> <Route path="/calendar" element={<Dashboard.CalendarScreen />} />
<Route path="/contacts" element={<Dashboard.ContactsScreen />} /> <Route path="/contacts" element={<Dashboard.ContactsScreen />} />
@@ -65,6 +63,7 @@ export function App() {
<Route path="/headscale/:section" element={<Dashboard.HeadscaleScreen />} /> <Route path="/headscale/:section" element={<Dashboard.HeadscaleScreen />} />
<Route path="/photos" element={<Dashboard.PhotosScreen />} /> <Route path="/photos" element={<Dashboard.PhotosScreen />} />
<Route path="/photos/:section" element={<Dashboard.PhotosScreen />} /> <Route path="/photos/:section" element={<Dashboard.PhotosScreen />} />
<Route path="/app-store" element={<Dashboard.AppStoreScreen />} />
<Route path="/jellyfin" element={<Dashboard.JellyfinScreen />} /> <Route path="/jellyfin" element={<Dashboard.JellyfinScreen />} />
<Route path="/jellyfin/:section" element={<Dashboard.JellyfinScreen />} /> <Route path="/jellyfin/:section" element={<Dashboard.JellyfinScreen />} />
<Route path="/transmission" element={<Dashboard.TransmissionScreen />} /> <Route path="/transmission" element={<Dashboard.TransmissionScreen />} />
@@ -0,0 +1,26 @@
import type { LayoutNode } from 'officerdev';
import { WorkspaceView } from 'officerdev';
import { useDashboardState } from 'state/useDashboardState';
import { defaultLayout } from './defaultLayout';
// /app-store — what this server can run, what it is running, and the four verbs that change it.
//
// Owner-only, and gated server-side: every route under /api/app-store refuses a non-owner before it
// reaches a handler. This screen is the courtesy half of that, and would show an empty store rather
// than a working one if it were ever reached by someone else.
//
// Which app is open lives in `?selected=`, read by both panels independently rather than passed between
// them — the list and the detail cannot disagree if neither is telling the other anything.
export const AppStoreScreen = () => {
const workspace = useDashboardState<LayoutNode>('screens/app-store', defaultLayout);
return (
<div className="h-full w-full pt-2">
<WorkspaceView
workspace={workspace}
locked
appTypes={{ allowed: ['app-store-list', 'app-store-detail'], fallback: 'app-store-detail' }}
/>
</div>
);
};
@@ -0,0 +1,14 @@
import type { LayoutNode } from 'officerdev';
// List on the left, detail on the right — a master list with a live preview, which is why the selection
// is `?selected=` rather than a detail route: linking rows to /app-store/:id would make the detail the
// whole page and destroy the side-by-side. See docs/navigation-audit.md.
export const defaultLayout: LayoutNode = {
type: 'group',
id: 'app-store-root',
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: 'app-store-list', appType: 'app-store-list' }, size: 30 },
{ node: { type: 'panel', id: 'app-store-detail', appType: 'app-store-detail' }, size: 70 },
],
};
@@ -0,0 +1 @@
export * from './AppStoreScreen';
@@ -6,20 +6,26 @@ import { ErrorBoundary } from '@/components/ErrorBoundary';
import { ScreenErrorFallback } from './ScreenErrorFallback'; import { ScreenErrorFallback } from './ScreenErrorFallback';
import { Background } from './Background'; import { Background } from './Background';
import { Header } from './Header'; import { Header } from './Header';
import { Dock, ALL_DOCK_ITEMS, DEFAULT_DOCK_PATHS } from './Dock'; import { Dock, CORE_DOCK_ITEMS, dockItemsFromPlugins, DEFAULT_DOCK_PATHS } from './Dock';
import { useIsTouch } from './useIsTouch'; import { useIsTouch } from './useIsTouch';
import { usePageTitleSync } from '@/state/usePageTitle'; import { usePageTitleSync } from '@/state/usePageTitle';
import { RouteGate } from './RouteGate';
type DashboardLayoutProps = { type DashboardLayoutProps = {
children?: React.ReactNode; children?: React.ReactNode;
}; };
export function DashboardLayout({ children }: DashboardLayoutProps) { export function DashboardLayout({ children }: DashboardLayoutProps) {
const { canVisit } = useCapabilities(); const { canVisit, plugins } = useCapabilities();
// Filtered BEFORE useDock, so a member's saved dock order cannot resurrect an icon their role no longer // Filtered BEFORE useDock, so a member's saved dock order cannot resurrect an icon their role no longer
// reaches, and so the pinned-item defaults fall back to something they can actually open. Cosmetic // reaches, and so the pinned-item defaults fall back to something they can actually open. Cosmetic
// either way — every one of these routes is refused server-side too — but an app that offers a door it // either way — every one of these routes is refused server-side too — but an app that offers a door it
// will then slam is worse than one that never showed it. // will then slam is worse than one that never showed it.
const permitted = useMemo(() => ALL_DOCK_ITEMS.filter((item) => canVisit(item.to)), [canVisit]); // The shell's own items plus whatever the installed sidecars contribute. `plugins` already excludes
// anything uninstalled or disabled, so an absent feature has no tile at all rather than a dead one.
const permitted = useMemo(
() => [...CORE_DOCK_ITEMS, ...dockItemsFromPlugins(plugins)].filter((item) => canVisit(item.to)),
[canVisit, plugins],
);
const { items: visibleItems } = useDock(permitted, DEFAULT_DOCK_PATHS); const { items: visibleItems } = useDock(permitted, DEFAULT_DOCK_PATHS);
const isTouch = useIsTouch(); const isTouch = useIsTouch();
const { pathname } = useLocation(); const { pathname } = useLocation();
@@ -50,7 +56,10 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
resetKeys={[pathname]} resetKeys={[pathname]}
fallback={({ error, reset }) => <ScreenErrorFallback error={error} reset={reset} />} fallback={({ error, reset }) => <ScreenErrorFallback error={error} reset={reset} />}
> >
{children} {/* Inside the boundary and around every screen, so one place decides whether a route exists for
this account on this server. Filtering the dock was never enough: the tile was hidden and the
route still rendered for anyone who typed it, followed an old link or restored a tab. */}
<RouteGate>{children}</RouteGate>
</ErrorBoundary> </ErrorBoundary>
</div> </div>
</section> </section>
@@ -1,6 +1,8 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { NavLink } from 'react-router'; import { NavLink } from 'react-router';
import type { LucideIcon } from 'lucide-react'; import type { LucideIcon } from 'lucide-react';
import { resolveIcon } from 'officerdev';
import type { PluginManifest } from 'hooks/useCapabilities';
export type DockItem = { export type DockItem = {
label: string; label: string;
@@ -147,34 +149,67 @@ import {
Contact, Contact,
Clapperboard, Clapperboard,
GitBranch, GitBranch,
Store,
} from 'lucide-react'; } from 'lucide-react';
export const ALL_DOCK_ITEMS: DockItem[] = [ /**
* The dock items that belong to the SHELL present on every install, with no sidecar behind them.
*
* Everything else is contributed by an installed sidecar's UI manifest and arrives from
* `/capabilities` at runtime (see `dockItemsFromPlugins`). The split is the point: a feature that can be
* installed and uninstalled must not be hardcoded here, or the dock would list things this server does
* not have and the shell would need editing every time a sidecar is added.
*
* These are the baseline chat, files, the terminal and the app's own screens plus Gitea, which is in
* the light profile because it fronts a remote instance and installs nothing locally.
*/
export const CORE_DOCK_ITEMS: DockItem[] = [
{ label: 'Home', to: '/', icon: Home, color: '#f59e0b' }, { label: 'Home', to: '/', icon: Home, color: '#f59e0b' },
{ label: 'Files', to: '/files', icon: FolderOpen, color: '#fbbf24' }, { label: 'Files', to: '/files', icon: FolderOpen, color: '#fbbf24' },
{ label: 'Email', to: '/email', icon: Mail, color: '#ef4444' },
{ label: 'Calendar', to: '/calendar', icon: CalendarDays, color: '#3b82f6' },
{ label: 'Contacts', to: '/contacts', icon: Contact, color: '#0ea5e9' },
{ label: 'Chat', to: '/chat', icon: MessageCircle, color: '#60a5fa' }, { label: 'Chat', to: '/chat', icon: MessageCircle, color: '#60a5fa' },
{ label: 'Music', to: '/music', icon: Music, color: '#22c55e' },
{ label: 'Photos', to: '/photos', icon: Images, color: '#10b981' },
{ label: 'Video', to: '/jellyfin', icon: Clapperboard, color: '#a855f7' },
{ label: 'Soulseek', to: '/soulseek', image: '/slskd.png', color: '#ffffff' },
{ label: 'Headscale', to: '/headscale', icon: Network, color: '#818cf8' },
{ label: 'Transmission', to: '/transmission', icon: ArrowDownUp, color: '#e11d48' },
{ label: 'Wallet', to: '/wallet', icon: Bitcoin, color: '#f7931a' },
{ label: 'Invoices', to: '/invoices', icon: Receipt, color: '#0891b2' },
{ label: 'Gitea', to: '/gitea', icon: GitBranch, color: '#609926' }, { label: 'Gitea', to: '/gitea', icon: GitBranch, color: '#609926' },
{ label: 'Editor', to: '/code-editor', icon: Code, color: '#a78bfa' }, { label: 'Editor', to: '/code-editor', icon: Code, color: '#a78bfa' },
{ label: 'Plans', to: '/plans', icon: FileText, color: '#f472b6' },
{ label: 'Jobs', to: '/jobs', icon: Workflow, color: '#14b8a6' }, { label: 'Jobs', to: '/jobs', icon: Workflow, color: '#14b8a6' },
{ label: 'Logs', to: '/task-logs', icon: ScrollText, color: '#94a3b8' }, { label: 'Logs', to: '/task-logs', icon: ScrollText, color: '#94a3b8' },
{ label: 'Terminal', to: '/terminal', icon: Monitor, color: '#f97316' }, { label: 'Terminal', to: '/terminal', icon: Monitor, color: '#f97316' },
{ label: 'Browser', to: '/browser', icon: Globe, color: '#06b6d4' }, { label: 'Browser', to: '/browser', icon: Globe, color: '#06b6d4' },
{ label: 'Desktop', to: '/desktop', icon: MonitorSmartphone, color: '#ec4899' },
{ label: 'Monitor', to: '/system-monitor', icon: Activity, color: '#0ea5e9' }, { label: 'Monitor', to: '/system-monitor', icon: Activity, color: '#0ea5e9' },
{ label: 'Activity', to: '/activity', icon: Radio, color: '#f59e0b' }, { label: 'Activity', to: '/activity', icon: Radio, color: '#f59e0b' },
{ label: 'Dashboards', to: '/dashboards', icon: LayoutGrid, color: '#8b5cf6' }, { label: 'Dashboards', to: '/dashboards', icon: LayoutGrid, color: '#8b5cf6' },
// Core by necessity: the store is how every other feature arrives, so it can never be one of the
// things that disappears when uninstalled.
{ label: 'App store', to: '/app-store', icon: Store, color: '#64748b' },
]; ];
export const DEFAULT_DOCK_PATHS = ['/', '/files', '/music', '/dashboards', '/chat']; /**
* Turn the manifests of installed sidecars into dock tiles.
*
* `resolveIcon` maps a NAME to a glyph, which is why manifests carry names rather than imports they
* have to survive being JSON from a marketplace. An unknown name resolves to a neutral box rather than
* throwing: a plugin naming an icon this build does not have should look plain, not break the dock.
*/
export function dockItemsFromPlugins(plugins: PluginManifest[]): DockItem[] {
return plugins.flatMap((plugin) => {
const tile = (t: { name: string; icon?: string; image?: string; color: string; route: string }): DockItem => ({
label: t.name,
to: t.route,
color: t.color,
...(t.image ? { image: t.image } : { icon: resolveIcon(t.icon ?? 'Box') }),
});
return [
tile({ name: plugin.name, icon: plugin.icon, image: plugin.image, color: plugin.color, route: plugin.rootRoute }),
...(plugin.extraTiles ?? []).map(tile),
];
});
}
/**
* What is pinned before anyone has chosen. Deliberately drawn only from CORE_DOCK_ITEMS.
*
* This used to pin `/music`, which is now an installable sidecar. `useDock` drops a path with no item
* behind it, so nothing breaks the default dock just quietly comes up one tile short on a machine
* where Music was never installed. Defaults that reference optional features are how an app ends up
* looking subtly wrong on a fresh install for no stated reason.
*/
export const DEFAULT_DOCK_PATHS = ['/', '/files', '/terminal', '/dashboards', '/chat'];
@@ -2,21 +2,41 @@ import { useState, useEffect } from 'react';
import { Link } from 'react-router'; import { Link } from 'react-router';
import { Loader2, ListOrdered } from 'lucide-react'; import { Loader2, ListOrdered } from 'lucide-react';
import { useClient } from 'hooks/useClient'; import { useClient } from 'hooks/useClient';
import { useCapabilities } from 'hooks/useCapabilities';
type Counts = { running: number; runningJobId: string | null; queued: number }; type Counts = { running: number; runningJobId: string | null; queued: number };
// Always-present header badges: how many jobs are running (→ the running job) and queued (→ the queue). // Header badges: how many jobs are running (→ the running job) and queued (→ the queue).
//
// Shown only to an account that holds `tasks`, which today means the owner — the queue runs scripts as the
// server owner and is `kind: 'execution'`. It used to render for everyone and poll `/jobs/counts` every
// three seconds regardless, so a member's console filled with 403s at 20 a minute and the header offered two
// links to a screen they cannot open. Neither is a security problem; both are the app lying about what it is.
export const JobsIndicator = () => { export const JobsIndicator = () => {
const client = useClient(); const client = useClient();
const { can } = useCapabilities();
const allowed = can('tasks');
const [counts, setCounts] = useState<Counts>({ running: 0, runningJobId: null, queued: 0 }); const [counts, setCounts] = useState<Counts>({ running: 0, runningJobId: null, queued: 0 });
useEffect(() => { useEffect(() => {
// Guarded inside the effect as well as at the render below, because the timer is the expensive half:
// an early return in the body would still leave an interval running from a previous render.
if (!allowed) return;
let alive = true; let alive = true;
const load = () => client.get<Counts>('/jobs/counts').then((c) => alive && setCounts(c)).catch(() => {}); const load = () =>
client
.get<Counts>('/jobs/counts')
.then((c) => alive && setCounts(c))
.catch(() => {});
load(); load();
const timer = setInterval(load, 3000); const timer = setInterval(load, 3000);
return () => { alive = false; clearInterval(timer); }; return () => {
}, []); alive = false;
clearInterval(timer);
};
}, [allowed]);
if (!allowed) return null;
const pill = 'flex items-center gap-1 h-8 px-2.5 rounded-full text-xs font-semibold tabular-nums transition-colors'; const pill = 'flex items-center gap-1 h-8 px-2.5 rounded-full text-xs font-semibold tabular-nums transition-colors';
@@ -3,6 +3,7 @@ import { RotateCw } from 'lucide-react';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useClient } from 'hooks/useClient'; import { useClient } from 'hooks/useClient';
import { useCapabilities } from 'hooks/useCapabilities';
type RescanResponse = { ok: boolean; counts: Record<string, number> }; type RescanResponse = { ok: boolean; counts: Record<string, number> };
@@ -13,8 +14,14 @@ const ITEM_QUERY_KEYS = ['tasks', 'task-categories', 'skills', 'tools', 'process
export function RescanButton() { export function RescanButton() {
const client = useClient(); const client = useClient();
const qc = useQueryClient(); const qc = useQueryClient();
const { can } = useCapabilities();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
// `POST /api/rescan` belongs to the `items` capability — skills, tools, agents and processes on the
// owner's disk, `kind: 'execution'`. A member pressing this got a 403 and a red toast about a feature
// whose existence is not their business.
if (!can('items')) return null;
const rescan = async () => { const rescan = async () => {
if (loading) return; if (loading) return;
setLoading(true); setLoading(true);
@@ -0,0 +1,36 @@
import { Navigate, useLocation } from 'react-router';
import { useCapabilities } from 'hooks/useCapabilities';
// A screen exists only if this server has the thing behind it and this account may reach it. Otherwise the
// path is treated exactly as an unknown one: redirect home, same as App.tsx's `path="*"`.
//
// ── Why a redirect and not an explanation ──
//
// The first version of this rendered a panel saying "Music is not installed" with a link to the app store,
// on the reasoning that a redirect erases what you asked for. That was wrong, and the owner's correction is
// the better principle: a naked platform should not know about a sidecar it does not have. Explaining the
// absence of Music is the app describing a feature that, as far as this server is concerned, does not exist —
// and it leaks the whole catalogue of what could be installed to every member who types a URL.
//
// So "no such page" is the honest answer, and it is the same answer for a member without a grant, for an
// owner whose sidecar is not installed, and for a typo. One behaviour, nothing disclosed.
//
// This is still a courtesy rather than the lock — every one of these routes is refused server-side too. What
// it stops is the app offering a door it will then slam.
//
// ── What this is NOT ──
//
// Routes are still declared in App.tsx for every screen, and this hides the ones that should not resolve. The
// end state the owner described is different and better: routes REGISTERED from the manifests of installed
// sidecars, so an uninstalled feature has no route to hide. The manifests already exist (`plugins`, carrying
// `rootRoute` and `routes`) and the dock is already built from them; the router is not, yet.
export function RouteGate({ children }: { children?: React.ReactNode }) {
const { pathname } = useLocation();
const { denialReason } = useCapabilities();
// `replace`, so Back does not bounce between the denied path and home.
if (denialReason(pathname)) return <Navigate to="/" replace />;
return <>{children}</>;
}
@@ -1,71 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { useNavigate, useParams } from 'react-router';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
import { useClient } from 'hooks/useClient';
import { Card } from '@/components/Card';
/**
* A plan is a markdown document on disk, so it gets an address: `/plans/:name`. No redirect guard the
* bare route is "no plan open" and a name that no longer exists gets the empty pane, not a rewritten URL.
*
* The picker stays a native `<select>` rather than becoming a link list. It is chrome for one document,
* not a master list, and a `<select>` is the right control for that on a phone; it navigates instead of
* setting state, which is what M4 was actually about.
*/
export const Plans = () => {
const client = useClient();
const navigate = useNavigate();
const selected = useParams<{ name: string }>().name ?? null;
const { data: plans = [] } = useQuery<string[]>({
queryKey: ['plans'],
queryFn: () => client.get<string[]>('/plans'),
});
const { data: content = '' } = useQuery<string>({
queryKey: ['plans', selected],
queryFn: () => client.getText(`/plans/${encodeURIComponent(selected!)}`),
enabled: !!selected,
});
return (
<div className="flex flex-col h-full p-4">
<Card className="flex-1 overflow-hidden">
<div className="shrink-0 flex items-center gap-3 px-4 py-2 border-b border-duck-dark/10 bg-background/60">
<span className="text-sm font-medium text-duck-dark/70">Plans</span>
{plans.length > 0 && (
<select
value={selected ?? ''}
onChange={(ev) => navigate(`/plans/${encodeURIComponent(ev.target.value)}`)}
className="text-xs border border-duck-dark/20 rounded px-2 py-1 bg-background/80 text-duck-dark"
>
{/* Only while nothing is chosen: it disappears once you pick, so it can never be picked back. */}
{!selected && <option value="">Select a plan</option>}
{plans.map((p) => (
<option key={p} value={p}>
{p}
</option>
))}
</select>
)}
</div>
<div className="overflow-y-auto h-full p-6">
{selected ? (
<div className="prose prose-sm dark:prose-invert max-w-none prose-headings:text-duck-dark prose-a:text-duck-teal prose-pre:bg-gray-900 prose-pre:text-green-400 prose-code:text-duck-teal prose-code:before:content-none prose-code:after:content-none prose-td:text-sm prose-th:text-sm">
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>
{content}
</ReactMarkdown>
</div>
) : (
<p className="text-sm text-duck-dark/50">
{plans.length === 0 ? 'No plans yet.' : 'Pick a plan to read it.'}
</p>
)}
</div>
</Card>
</div>
);
};
@@ -1,8 +1,9 @@
import { useState, useCallback, type DragEvent } from 'react'; import { useMemo, useState, useCallback, type DragEvent } from 'react';
import { X, Plus, RotateCcw } from 'lucide-react'; import { X, Plus, RotateCcw } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useDock } from 'officerdev'; import { useDock } from 'officerdev';
import { ALL_DOCK_ITEMS, DEFAULT_DOCK_PATHS } from '@/Screens/Dashboard/Layout/Dock'; import { useCapabilities } from 'hooks/useCapabilities';
import { CORE_DOCK_ITEMS, dockItemsFromPlugins, DEFAULT_DOCK_PATHS } from '@/Screens/Dashboard/Layout/Dock';
type DockPillProps = { type DockPillProps = {
label: string; label: string;
@@ -18,14 +19,32 @@ type DockPillProps = {
}; };
const DockPill = ({ const DockPill = ({
label, path, color, visible, onAction, onDragStart, onDropOnPill, dropIndicator, onDragOverPill, onDragLeavePill, label,
path,
color,
visible,
onAction,
onDragStart,
onDropOnPill,
dropIndicator,
onDragOverPill,
onDragLeavePill,
}: DockPillProps) => ( }: DockPillProps) => (
<span <span
draggable draggable
onDragStart={(ev) => onDragStart(ev, path)} onDragStart={(ev) => onDragStart(ev, path)}
onDragOver={onDragOverPill} onDragOver={onDragOverPill}
onDragLeave={onDragLeavePill} onDragLeave={onDragLeavePill}
onDrop={onDropOnPill ? (ev) => { ev.preventDefault(); ev.stopPropagation(); const p = ev.dataTransfer.getData('text/plain'); if (p) onDropOnPill(p); } : undefined} onDrop={
onDropOnPill
? (ev) => {
ev.preventDefault();
ev.stopPropagation();
const p = ev.dataTransfer.getData('text/plain');
if (p) onDropOnPill(p);
}
: undefined
}
className={`relative inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-medium cursor-grab active:cursor-grabbing select-none border border-duck-dark/15 dark:border-foreground/15 bg-background/60 text-duck-dark/80 dark:text-foreground/80 hover:bg-duck-dark/5 dark:hover:bg-foreground/5 transition-colors ${dropIndicator === 'left' ? 'ring-l-2 ring-duck-teal' : ''} ${dropIndicator === 'right' ? 'ring-r-2 ring-duck-teal' : ''}`} className={`relative inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-medium cursor-grab active:cursor-grabbing select-none border border-duck-dark/15 dark:border-foreground/15 bg-background/60 text-duck-dark/80 dark:text-foreground/80 hover:bg-duck-dark/5 dark:hover:bg-foreground/5 transition-colors ${dropIndicator === 'left' ? 'ring-l-2 ring-duck-teal' : ''} ${dropIndicator === 'right' ? 'ring-r-2 ring-duck-teal' : ''}`}
> >
{dropIndicator === 'left' && <span className="absolute -left-1 top-1 bottom-1 w-0.5 rounded-full bg-duck-teal" />} {dropIndicator === 'left' && <span className="absolute -left-1 top-1 bottom-1 w-0.5 rounded-full bg-duck-teal" />}
@@ -69,7 +88,9 @@ const DropZone = ({ label, children, onDrop }: DropZoneProps) => {
return ( return (
<div className="grid gap-1.5"> <div className="grid gap-1.5">
<span className="text-xs font-medium text-duck-dark/50 dark:text-foreground/50 uppercase tracking-wide">{label}</span> <span className="text-xs font-medium text-duck-dark/50 dark:text-foreground/50 uppercase tracking-wide">
{label}
</span>
<div <div
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDragLeave={handleDragLeave} onDragLeave={handleDragLeave}
@@ -83,7 +104,11 @@ const DropZone = ({ label, children, onDrop }: DropZoneProps) => {
}; };
export const DockSettings = () => { export const DockSettings = () => {
const { items, allItems, setItems, reset } = useDock(ALL_DOCK_ITEMS, DEFAULT_DOCK_PATHS); // Same composition as the dock itself. Offering a pin for an uninstalled feature would let someone
// pin a tile that cannot appear, which reads as the setting being broken.
const { plugins } = useCapabilities();
const allDockItems = useMemo(() => [...CORE_DOCK_ITEMS, ...dockItemsFromPlugins(plugins)], [plugins]);
const { items, allItems, setItems, reset } = useDock(allDockItems, DEFAULT_DOCK_PATHS);
const [dropTarget, setDropTarget] = useState<{ path: string; side: 'left' | 'right' } | null>(null); const [dropTarget, setDropTarget] = useState<{ path: string; side: 'left' | 'right' } | null>(null);
const visiblePaths = new Set(items.map((i) => i.to)); const visiblePaths = new Set(items.map((i) => i.to));
@@ -135,7 +160,10 @@ export const DockSettings = () => {
const handlePillDrop = useCallback( const handlePillDrop = useCallback(
(draggedPath: string, targetPath: string) => { (draggedPath: string, targetPath: string) => {
if (draggedPath === targetPath) { setDropTarget(null); return; } if (draggedPath === targetPath) {
setDropTarget(null);
return;
}
const side = dropTarget?.path === targetPath ? dropTarget.side : 'right'; const side = dropTarget?.path === targetPath ? dropTarget.side : 'right';
insertAt(draggedPath, targetPath, side); insertAt(draggedPath, targetPath, side);
}, },
@@ -163,7 +191,11 @@ export const DockSettings = () => {
return ( return (
<div className="grid gap-4"> <div className="grid gap-4">
<DropZone label="Visible" onDrop={onDropVisible}> <DropZone label="Visible" onDrop={onDropVisible}>
{items.length === 0 && <span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">Drag items here to show in dock</span>} {items.length === 0 && (
<span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">
Drag items here to show in dock
</span>
)}
{items.map((item) => ( {items.map((item) => (
<DockPill <DockPill
key={item.to} key={item.to}
@@ -182,7 +214,9 @@ export const DockSettings = () => {
</DropZone> </DropZone>
<DropZone label="Hidden" onDrop={onDropHidden}> <DropZone label="Hidden" onDrop={onDropHidden}>
{hiddenItems.length === 0 && <span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">All items visible</span>} {hiddenItems.length === 0 && (
<span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">All items visible</span>
)}
{hiddenItems.map((item) => ( {hiddenItems.map((item) => (
<DockPill <DockPill
key={item.to} key={item.to}
@@ -196,12 +230,7 @@ export const DockSettings = () => {
))} ))}
</DropZone> </DropZone>
<Button <Button type="button" variant="outline" onClick={reset} className="w-full h-9 text-sm cursor-pointer">
type="button"
variant="outline"
onClick={reset}
className="w-full h-9 text-sm cursor-pointer"
>
<RotateCcw className="h-3.5 w-3.5 mr-1.5" /> <RotateCcw className="h-3.5 w-3.5 mr-1.5" />
Reset to defaults Reset to defaults
</Button> </Button>
@@ -0,0 +1,332 @@
import { useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { Loader2, UserPlus, Dices, Copy, X } from 'lucide-react';
import { useClient } from 'hooks/useClient';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
// The owner creating an account. Until this existed the only way to add one was an INSERT in Postgres.
//
// The password is visible, not masked, and that is the point: the owner has to be able to read it back
// to the person they are creating it for. Masking a value nobody has yet only makes it easy to typo
// twice. When there is an invite flow this whole field goes away.
type CreateUserFormProps = {
/** Roles the server will actually accept. Excludes the owner role — see manage-users.ts. */
roles: string[];
/** Invalidated on success so the list below refreshes. */
usersKey: readonly unknown[];
};
// Mirrors validatePassword on the server: length, both cases, a digit and a symbol. Generated rather
// than demanded so the owner is not sitting there inventing one that passes.
function generatePassword(): string {
const lower = 'abcdefghijkmnopqrstuvwxyz';
const upper = 'ABCDEFGHJKLMNPQRSTUVWXYZ';
const digits = '23456789';
const symbols = '!@#$%^&*-_=+';
const all = lower + upper + digits + symbols;
const pick = (set: string, count: number) =>
Array.from(crypto.getRandomValues(new Uint32Array(count)), (n) => set[n % set.length]!);
// One of each class first, so the result cannot fail the server's rules by chance, then filled out.
const chars = [...pick(lower, 4), ...pick(upper, 3), ...pick(digits, 3), ...pick(symbols, 2), ...pick(all, 8)];
// Shuffled so the classes are not in fixed positions. Fisher-Yates with crypto randomness.
const noise = crypto.getRandomValues(new Uint32Array(chars.length));
for (let i = chars.length - 1; i > 0; i--) {
const j = noise[i]! % (i + 1);
[chars[i], chars[j]] = [chars[j]!, chars[i]!];
}
return chars.join('');
}
const EMPTY = { email: '', name: '', username: '', password: '', role: 'Member', sshPublicKey: '' };
/**
* What the server did, shown after the fact.
*
* Kept on screen rather than announced in a toast because two of these values are only obtainable now: the
* password is stored as an argon2 hash, and the generated public key sits in a 700 home. A toast that
* carries something unrecoverable is a toast that gets dismissed by a stray click.
*/
type CreatedAccount = {
email: string;
password: string;
osUser: string | null;
osSshPublicKey: string | null;
osUserError: string | null;
};
export const CreateUserForm = ({ roles, usersKey }: CreateUserFormProps) => {
const client = useClient();
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [form, setForm] = useState(EMPTY);
const [created, setCreated] = useState<CreatedAccount | null>(null);
const set = (key: keyof typeof EMPTY) => (value: string) => setForm((prev) => ({ ...prev, [key]: value }));
const close = () => {
setOpen(false);
setForm(EMPTY);
setCreated(null);
};
const submit = async (ev: React.FormEvent) => {
ev.preventDefault();
setSaving(true);
try {
const res = await client.post<{
user: { osUser: string | null; osSshPublicKey: string | null };
osUserError: string | null;
}>('/users', form);
await queryClient.invalidateQueries({ queryKey: usersKey });
setCreated({
email: form.email,
password: form.password,
osUser: res.user.osUser,
osSshPublicKey: res.user.osSshPublicKey,
osUserError: res.osUserError,
});
} catch (ex) {
// The server's message is the useful one here — which field, and why.
toast.error(ex instanceof Error ? ex.message : 'Could not create the account');
} finally {
setSaving(false);
}
};
const copy = (value: string, what: string) => {
void navigator.clipboard.writeText(value);
toast.success(`${what} copied`);
};
// ── After creation ──
//
// Deliberately a wall you have to dismiss. Both values below are unrecoverable once this closes, and the
// public key has a job attached to it that nothing else will remind you to do.
if (created) {
return (
<div className="space-y-4 rounded-lg border border-duck-teal/40 bg-duck-teal/5 p-4">
<div>
<h3 className="text-sm font-medium">{created.email} created</h3>
<p className="text-xs text-muted-foreground">
Copy what you need before closing none of it can be shown again.
</p>
</div>
<div className="space-y-1.5">
<Label>Password</Label>
<div className="flex gap-2">
<Input readOnly value={created.password} className="font-mono" />
<Button type="button" variant="outline" size="icon" onClick={() => copy(created.password, 'Password')}>
<Copy className="h-4 w-4" />
</Button>
</div>
<p className="text-xs text-muted-foreground">
Stored as a hash this is the only time it exists in readable form. They can change it from their own
profile once signed in.
</p>
</div>
{created.osUser && (
<div className="space-y-1.5">
<Label>Linux account</Label>
<Input readOnly value={created.osUser} className="font-mono" />
</div>
)}
{created.osSshPublicKey && (
<div className="space-y-1.5">
<Label>Their SSH public key</Label>
<div className="flex gap-2">
<Textarea readOnly value={created.osSshPublicKey} rows={3} className="font-mono text-xs" />
<Button
type="button"
variant="outline"
size="icon"
onClick={() => copy(created.osSshPublicKey!, 'Public key')}
>
<Copy className="h-4 w-4" />
</Button>
</div>
{/* The one action this screen cannot do for you. Without it their pushes fail with a
permission error that says nothing about a missing key. */}
<p className="text-xs text-muted-foreground">
Generated on the machine; the private half never leaves it.{' '}
<strong>Add this to their Gitea account</strong> so they can push. Retrievable later from their row.
</p>
</div>
)}
{created.osUserError && (
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-xs">
<div className="font-medium">The account works, but its Linux side did not finish</div>
<p className="mt-1 whitespace-pre-wrap text-muted-foreground">{created.osUserError}</p>
</div>
)}
<Button type="button" onClick={close}>
Done
</Button>
</div>
);
}
if (!open) {
return (
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
<UserPlus className="mr-2 h-4 w-4" />
Add account
</Button>
);
}
return (
<form onSubmit={submit} className="space-y-4 rounded-lg border p-4">
<div className="flex items-start justify-between gap-2">
<div>
<h3 className="text-sm font-medium">New account</h3>
<p className="text-xs text-muted-foreground">
Created active they can sign in straight away. Tell them the password; it is not recoverable afterwards.
</p>
</div>
<Button type="button" variant="ghost" size="icon" onClick={close} aria-label="Cancel">
<X className="h-4 w-4" />
</Button>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="new-user-email">Email</Label>
<Input
id="new-user-email"
type="email"
autoComplete="off"
value={form.email}
onChange={(ev) => set('email')(ev.target.value)}
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="new-user-name">Name</Label>
<Input
id="new-user-name"
autoComplete="off"
value={form.name}
onChange={(ev) => set('name')(ev.target.value)}
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="new-user-username">Username</Label>
<Input
id="new-user-username"
autoComplete="off"
value={form.username}
onChange={(ev) => set('username')(ev.target.value)}
required
/>
<p className="text-xs text-muted-foreground">Letters, numbers, dots, hyphens and underscores.</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="new-user-role">Role</Label>
<Select value={form.role} onValueChange={set('role')}>
<SelectTrigger id="new-user-role">
<SelectValue />
</SelectTrigger>
<SelectContent>
{roles.map((role) => (
<SelectItem key={role} value={role}>
{role}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
What the role may reach is set under Permissions. A role with no grants can sign in and reach nothing but
its own profile.
</p>
</div>
<div className="space-y-1.5 sm:col-span-2">
<Label htmlFor="new-user-password">Password</Label>
<div className="flex gap-2">
<Input
id="new-user-password"
// Deliberately visible — see the note at the top of this file.
type="text"
autoComplete="off"
spellCheck={false}
className="font-mono"
value={form.password}
onChange={(ev) => set('password')(ev.target.value)}
required
/>
<Button type="button" variant="outline" size="icon" onClick={() => set('password')(generatePassword())}>
<Dices className="h-4 w-4" />
<span className="sr-only">Generate a password</span>
</Button>
<Button
type="button"
variant="outline"
size="icon"
disabled={!form.password}
onClick={() => {
void navigator.clipboard.writeText(form.password);
toast.success('Password copied');
}}
>
<Copy className="h-4 w-4" />
<span className="sr-only">Copy the password</span>
</Button>
</div>
<p className="text-xs text-muted-foreground">
At least 12 characters, with upper and lower case, a number and a symbol.
</p>
</div>
{/* Inbound only, and optional. The OUTBOUND key is generated either way pasting one here does
not replace it, because a key on a laptop is no use to an agent running on the server. */}
<div className="space-y-1.5 sm:col-span-2">
<Label htmlFor="new-user-ssh">
Their SSH public key <span className="ml-1 text-xs opacity-60">(optional)</span>
</Label>
<Textarea
id="new-user-ssh"
rows={3}
spellCheck={false}
placeholder="ssh-ed25519 AAAAC3Nza… ana@laptop"
className="font-mono text-xs"
value={form.sshPublicKey}
onChange={(ev) => set('sshPublicKey')(ev.target.value)}
/>
<p className="text-xs text-muted-foreground">
Lets them SSH into this machine as their own Linux user. Leave empty for platform-only access either way
they get a keypair of their own for pushing to Gitea, and you will be shown its public half next.
</p>
</div>
</div>
<div className="flex gap-2">
<Button type="submit" disabled={saving}>
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Create account
</Button>
<Button type="button" variant="ghost" onClick={close} disabled={saving}>
Cancel
</Button>
</div>
</form>
);
};
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Loader2, Lock } from 'lucide-react'; import { Loader2 } from 'lucide-react';
import { useClient } from 'hooks/useClient'; import { useClient } from 'hooks/useClient';
import { CAPABILITIES_QUERY_KEY } from 'hooks/useCapabilities'; import { CAPABILITIES_QUERY_KEY } from 'hooks/useCapabilities';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -25,7 +25,9 @@ type CapabilityInfo = {
type Grant = { role: string; capability: string; level: 'read' | 'write' }; type Grant = { role: string; capability: string; level: 'read' | 'write' };
type CapabilitiesResponse = { type CapabilitiesResponse = {
/** Grantable AND installed. What this server can currently do. */
capabilities: CapabilityInfo[]; capabilities: CapabilityInfo[];
roles: string[]; roles: string[];
grants: Grant[]; grants: Grant[];
}; };
@@ -97,21 +99,27 @@ export const PermissionsSection = () => {
return ( return (
<div className="flex flex-col gap-5 p-1"> <div className="flex flex-col gap-5 p-1">
{/* Tabs rather than a dropdown. There are three roles and they are the axis you move along a select
hides two of them behind a click and gives no sense of "which one am I editing" at a glance. Real
buttons, because switching role mutates a draft rather than navigating. */}
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-1 rounded-lg border p-1" role="tablist" aria-label="Role">
<span className="text-sm text-muted-foreground">Role</span>
<Select value={activeRole ?? undefined} onValueChange={(value) => setRole(value)}>
<SelectTrigger className="w-44">
<SelectValue />
</SelectTrigger>
<SelectContent>
{data.roles.map((r) => ( {data.roles.map((r) => (
<SelectItem key={r} value={r}> <button
key={r}
type="button"
role="tab"
aria-selected={activeRole === r}
onClick={() => setRole(r)}
className={`rounded-md px-3 py-1.5 text-sm transition-colors ${
activeRole === r
? 'bg-accent font-medium text-accent-foreground'
: 'text-muted-foreground hover:bg-accent/50'
}`}
>
{r} {r}
</SelectItem> </button>
))} ))}
</SelectContent>
</Select>
</div> </div>
<Button onClick={save} disabled={!dirty || saving}> <Button onClick={save} disabled={!dirty || saving}>
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} {saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
@@ -130,10 +138,11 @@ export const PermissionsSection = () => {
const level = draft[capability.key] ?? 'none'; const level = draft[capability.key] ?? 'none';
return ( return (
<div key={capability.key} className="flex items-center justify-between gap-4 p-3"> <div key={capability.key} className="flex items-center justify-between gap-4 p-3">
<div className="min-w-0"> {/* Label only. The descriptions went because with three rows called Terminal, Chat and Files
<div className="text-sm font-medium">{capability.label}</div> they explained nothing anyone needed and the "needs a Linux account" line went with them:
<div className="text-xs text-muted-foreground">{capability.description}</div> every account gets one at creation, so warning about it on every row was noise about a state
</div> that no longer occurs on its own. */}
<div className="min-w-0 text-sm font-medium">{capability.label}</div>
<Select <Select
value={level} value={level}
onValueChange={(value) => setDraft((prev) => ({ ...prev, [capability.key]: value as Level }))} onValueChange={(value) => setDraft((prev) => ({ ...prev, [capability.key]: value as Level }))}
@@ -152,19 +161,12 @@ export const PermissionsSection = () => {
})} })}
</div> </div>
{/* Stated rather than silently omitted. An owner who cannot find the Terminal checkbox will assume {/* Two explanatory blocks used to sit here: one naming every capability whose sidecar is not installed,
the screen is incomplete and go looking for it; saying why it does not exist is the difference and one naming everything that can never be granted. Both are gone, and for the same reason a
between a deliberate design and a missing feature. */} server should not enumerate what it does not have. The first was a catalogue of uninstallable
<div className="flex gap-3 rounded-lg border border-dashed p-3 text-xs text-muted-foreground"> features presented as a permissions decision; the second described chat, tasks, the desktop and the
<Lock className="mt-0.5 h-4 w-4 shrink-0" /> wallet to an owner who may have none of them installed. What is on this screen is what this server
<div> can actually do. */}
<div className="font-medium text-foreground">Not listed, and not grantable</div>
The terminal, chat, tasks, files, the code editor, the desktop and the browser all run as the server owner, in
the server owner&rsquo;s home directory, with full permissions. Granting one of them would hand over the
machine rather than a feature, so there is no level at which they can be shared. The wallet, Headscale and the
server settings stay with the owner for the same reason.
</div>
</div>
</div> </div>
); );
}; };
@@ -1,7 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Crown, Trash2, Loader2 } from 'lucide-react'; import { Crown, Trash2, Loader2, KeyRound, SquareTerminal as TerminalIcon } from 'lucide-react';
import { useClient } from 'hooks/useClient'; import { useClient } from 'hooks/useClient';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
@@ -15,6 +15,7 @@ import {
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
} from '@/components/ui/alert-dialog'; } from '@/components/ui/alert-dialog';
import { CreateUserForm } from './CreateUserForm';
type ManagedUser = { type ManagedUser = {
id: number; id: number;
@@ -25,9 +26,20 @@ type ManagedUser = {
role: string; role: string;
createdAt: string; createdAt: string;
isOwner: boolean; isOwner: boolean;
osUser: string | null;
osSshPublicKey: string | null;
}; };
type UsersResponse = { users: ManagedUser[]; roles: string[]; ownerId: number }; type UsersResponse = {
users: ManagedUser[];
/** False on a host without per-user Linux accounts, where those controls would only ever refuse. */
osUsersEnabled: boolean;
/** Every role, for displaying the owner's own value. */
roles: string[];
/** Roles the server will accept in a write. Excludes the owner role — both write paths refuse it. */
assignableRoles: string[];
ownerId: number;
};
const USERS_KEY = ['MANAGED_USERS']; const USERS_KEY = ['MANAGED_USERS'];
@@ -56,6 +68,45 @@ export const UsersSection = () => {
} }
}; };
/**
* Create or repair the account's Linux side in place.
*
* Prompts for a key rather than putting a whole form on the row: replacing it is the rarer of the two
* reasons to press this, and an empty answer means "leave authorized_keys alone" rather than "remove it".
*/
const provisionLinux = async (user: ManagedUser) => {
const key = window.prompt(
`Linux account for ${user.email}.\n\n` +
`Paste an SSH public key to allow them to SSH in, or leave empty to keep the current one.`,
'',
);
// Cancel is null; empty string is a deliberate "no change".
if (key === null) return;
setPendingId(user.id);
try {
const result = await client.post<{ osUser: string | null; sshPublicKey: string | null; error: string | null }>(
`/users/${user.id}/provision-linux`,
{ sshPublicKey: key.trim() },
);
await queryClient.invalidateQueries({ queryKey: USERS_KEY });
// Partial success is the interesting case and must not read as a clean win: the account can exist and
// be confined while the keys failed.
if (result.error) {
toast.warning(result.osUser ? `${result.osUser} created, but not finished` : 'Could not finish', {
description: result.error,
duration: 30_000,
});
} else {
toast.success(`${result.osUser} is ready`);
}
} catch (ex) {
toast.error(ex instanceof Error ? ex.message : 'Could not provision the Linux account');
} finally {
setPendingId(null);
}
};
const remove = async (user: ManagedUser) => { const remove = async (user: ManagedUser) => {
setPendingId(user.id); setPendingId(user.id);
setConfirmDelete(null); setConfirmDelete(null);
@@ -86,9 +137,11 @@ export const UsersSection = () => {
<div className="space-y-4"> <div className="space-y-4">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Every account on this server. The owner is fixed the database itself refuses to demote or remove it so that Every account on this server. The owner is fixed the database itself refuses to demote or remove it so that
row cannot be changed from here. row cannot be changed from here, and no other account can be promoted into it.
</p> </p>
<CreateUserForm roles={data.assignableRoles} usersKey={USERS_KEY} />
<div className="rounded-lg border divide-y"> <div className="rounded-lg border divide-y">
{data.users.map((user) => { {data.users.map((user) => {
const busy = pendingId === user.id; const busy = pendingId === user.id;
@@ -102,6 +155,9 @@ export const UsersSection = () => {
<div className="truncate text-xs text-muted-foreground"> <div className="truncate text-xs text-muted-foreground">
{user.email} {user.email}
{user.status !== 'Active' && ` · ${user.status}`} {user.status !== 'Active' && ` · ${user.status}`}
{/* Shown because "does this person have a Linux account" is otherwise invisible, and it
decides whether their terminal and agent run as them or not at all. */}
{user.osUser && ` · ${user.osUser}`}
</div> </div>
</div> </div>
@@ -114,7 +170,9 @@ export const UsersSection = () => {
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{data.roles.map((role) => ( {/* The owner's row needs its own value present to render at all, and it is disabled
anyway. Every other row offers only what the server will accept. */}
{(user.isOwner ? data.roles : data.assignableRoles).map((role) => (
<SelectItem key={role} value={role}> <SelectItem key={role} value={role}>
{role} {role}
</SelectItem> </SelectItem>
@@ -122,6 +180,45 @@ export const UsersSection = () => {
</SelectContent> </SelectContent>
</Select> </Select>
{/* Create or repair the Linux side. Offered for anyone lacking an account (backfill) and for
anyone who has one (retry after fixing a host problem, or replace their key) the
underlying operation is idempotent, so there is no state where pressing it is wrong. */}
{data.osUsersEnabled && !user.isOwner && (
<Button
variant="ghost"
size="icon"
className={`shrink-0 ${user.osUser ? 'text-muted-foreground' : 'text-amber-500'}`}
disabled={busy}
aria-label={user.osUser ? `Repair ${user.email}'s Linux account` : `Create a Linux account`}
title={
user.osUser
? `Linux account: ${user.osUser} — click to repair or replace their SSH key`
: 'No Linux account — click to create one'
}
onClick={() => void provisionLinux(user)}
>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <TerminalIcon className="h-4 w-4" />}
</Button>
)}
{/* The errand the create screen promised would still be here: this key has to end up on
their Gitea account, and nothing else will remind anyone. */}
{user.osSshPublicKey && (
<Button
variant="ghost"
size="icon"
className="shrink-0 text-muted-foreground"
aria-label={`Copy ${user.email}'s SSH public key`}
title="Copy their SSH public key (add it to their Gitea account)"
onClick={() => {
void navigator.clipboard.writeText(user.osSshPublicKey!);
toast.success('Public key copied');
}}
>
<KeyRound className="h-4 w-4" />
</Button>
)}
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@@ -1,7 +1,7 @@
export * from './AppStore';
export * from './Layout'; export * from './Layout';
export * from './Home'; export * from './Home';
export * from './PasskeyGate'; export * from './PasskeyGate';
export * from './Plans';
export * from './Processes'; export * from './Processes';
export * from './CapabilityPage'; export * from './CapabilityPage';
export * from './Settings'; export * from './Settings';
+3 -3
View File
@@ -1,15 +1,15 @@
import { usePlans } from 'state/usePlans';
import { useSettings } from 'state/useSettings'; import { useSettings } from 'state/useSettings';
import { useModels } from 'state/useModels'; import { useModels } from 'state/useModels';
import { useAccessPolicy } from 'state/useAccessPolicy'; import { useAccessPolicy } from 'state/useAccessPolicy';
import { useColorModeSync } from './useThemeSync'; import { useColorModeSync } from './useThemeSync';
// Caches the shell wants warm before anything asks for them. Called once from App.tsx for its effects —
// the return value has never been read.
export const useInitialData = () => { export const useInitialData = () => {
const { plans } = usePlans();
const { settings } = useSettings(); const { settings } = useSettings();
useModels(); useModels();
useAccessPolicy(); useAccessPolicy();
useColorModeSync(); useColorModeSync();
return { plans, settings }; return { settings };
}; };
+1 -1
View File
@@ -21,6 +21,7 @@ const RULES: TitleRule[] = [
{ match: (p) => p.startsWith('/calendar'), title: 'Calendar' }, { match: (p) => p.startsWith('/calendar'), title: 'Calendar' },
{ match: (p) => p.startsWith('/contacts'), title: 'Contacts' }, { match: (p) => p.startsWith('/contacts'), title: 'Contacts' },
{ match: (p) => p.startsWith('/music'), title: 'Music' }, { match: (p) => p.startsWith('/music'), title: 'Music' },
{ match: (p) => p.startsWith('/app-store'), title: 'App store' },
{ match: (p) => p.startsWith('/photos'), title: 'Photos' }, { match: (p) => p.startsWith('/photos'), title: 'Photos' },
{ match: (p) => p.startsWith('/jellyfin'), title: 'Video' }, { match: (p) => p.startsWith('/jellyfin'), title: 'Video' },
{ match: (p) => p.startsWith('/soulseek'), title: 'Soulseek' }, { match: (p) => p.startsWith('/soulseek'), title: 'Soulseek' },
@@ -42,7 +43,6 @@ const RULES: TitleRule[] = [
{ match: (p) => p.startsWith('/terminal'), title: 'Terminal' }, { match: (p) => p.startsWith('/terminal'), title: 'Terminal' },
{ match: (p) => p.startsWith('/browser'), title: 'Browser' }, { match: (p) => p.startsWith('/browser'), title: 'Browser' },
{ match: (p) => p.startsWith('/desktop'), title: 'Desktop' }, { match: (p) => p.startsWith('/desktop'), title: 'Desktop' },
{ match: (p) => p.startsWith('/plans'), title: 'Plans' },
]; ];
export function titleForPath(pathname: string): string { export function titleForPath(pathname: string): string {
+15
View File
@@ -2,6 +2,7 @@ export {
getUsers, getUsers,
getUserById, getUserById,
getUserByEmail, getUserByEmail,
getUserByUsername,
getOwnerUser, getOwnerUser,
getUserCount, getUserCount,
createUser, createUser,
@@ -280,3 +281,17 @@ export {
markPushDeviceSeen, markPushDeviceSeen,
} from './queries/notify'; } from './queries/notify';
export type { PushDeviceSelect, PushDeviceInsert } from './types'; export type { PushDeviceSelect, PushDeviceInsert } from './types';
// App store — what the owner has installed, and whether it should be running.
export {
listSidecarInstalls,
getSidecarInstall,
beginInstall,
recordSteps,
markInstalled,
markFailed,
markBlocked,
setEnabled,
removeInstall,
type SidecarInstall,
} from './queries/sidecar-installs';
+16 -1
View File
@@ -19,6 +19,14 @@ export async function getUserByEmail(email: string): Promise<UserSelect | undefi
return user; return user;
} }
// `username` is unique in the schema, so this exists to turn a would-be constraint violation into a
// sentence. Creating an account is a form someone fills in, and "duplicate key value violates unique
// constraint users_username_unique" is not an answer to give them.
export async function getUserByUsername(username: string): Promise<UserSelect | undefined> {
const [user] = await db.select().from(users).where(eq(users.username, username));
return user;
}
// The owner: the one account with role 'Super Admin'. Sidecars that need "who is the owner" (e.g. the // The owner: the one account with role 'Super Admin'. Sidecars that need "who is the owner" (e.g. the
// agent sidecar, which PM2 starts with no email in its env) resolve it here rather than being told by // agent sidecar, which PM2 starts with no email in its env) resolve it here rather than being told by
// the main server. // the main server.
@@ -27,8 +35,15 @@ export async function getUserByEmail(email: string): Promise<UserSelect | undefi
// now makes, asserted a second way, and the two would part company the moment the owner was not user // now makes, asserted a second way, and the two would part company the moment the owner was not user
// #1. Returns undefined rather than falling back to the lowest id when no row holds the role: the agent // #1. Returns undefined rather than falling back to the lowest id when no row holds the role: the agent
// sidecar refusing to start beats it silently running as the wrong person. // sidecar refusing to start beats it silently running as the wrong person.
//
// `order by id` is not cosmetic. Without it, two rows holding the role would make "who owns this
// server" whatever Postgres happened to return first — and that answer feeds the agent sidecar's
// identity, the vault and origin scoping. The write paths refuse to create a second Super Admin
// (create-user.ts and updateUserRoleHandler), so this should never have a choice to make; ordering is
// what makes the outcome deterministic if one ever gets in by another route, and id 1 is the bootstrap
// account the CHECK constraint already pins.
export async function getOwnerUser(): Promise<UserSelect | undefined> { export async function getOwnerUser(): Promise<UserSelect | undefined> {
const [user] = await db.select().from(users).where(eq(users.role, 'Super Admin')).limit(1); const [user] = await db.select().from(users).where(eq(users.role, 'Super Admin')).orderBy(users.id).limit(1);
return user; return user;
} }
@@ -0,0 +1,109 @@
import { eq } from 'drizzle-orm';
import { db } from '../db';
import { sidecarInstalls } from '../schema';
// What the owner has installed from the app store. See ../schema/app-store.ts for why there is no
// userId and why `installed` and `enabled` are separate.
export type SidecarInstall = typeof sidecarInstalls.$inferSelect;
export async function listSidecarInstalls(): Promise<SidecarInstall[]> {
return db.select().from(sidecarInstalls);
}
export async function getSidecarInstall(sidecarId: string): Promise<SidecarInstall | null> {
const [row] = await db.select().from(sidecarInstalls).where(eq(sidecarInstalls.sidecarId, sidecarId));
return row ?? null;
}
/**
* Create the row for an install that is about to start, or pick up the one a previous attempt left.
*
* Returning the existing row rather than replacing it is what makes a retry a RESUME: `completedSteps`
* is how the installer knows not to provision a second container, and starting fresh would throw that
* away every time someone pressed the button again after a failure.
*/
export async function beginInstall(sidecarId: string, mode: string): Promise<SidecarInstall> {
const existing = await getSidecarInstall(sidecarId);
if (existing) {
const [row] = await db
.update(sidecarInstalls)
// `lastError` cleared on the way in: it describes the PREVIOUS attempt, and leaving it visible
// while a new one runs is how a UI ends up showing a stale failure next to a working service.
.set({ status: 'installing', mode, lastError: null, updatedAt: new Date() })
.where(eq(sidecarInstalls.sidecarId, sidecarId))
.returning();
return row!;
}
const [row] = await db.insert(sidecarInstalls).values({ sidecarId, mode, status: 'installing' }).returning();
return row!;
}
/** Record progress mid-install, so an interrupted run can be resumed rather than restarted. */
export async function recordSteps(sidecarId: string, completedSteps: string[], composeDir?: string): Promise<void> {
await db
.update(sidecarInstalls)
.set({ completedSteps, ...(composeDir ? { composeDir } : {}), updatedAt: new Date() })
.where(eq(sidecarInstalls.sidecarId, sidecarId));
}
/** Install finished. `enabled` goes true here because installing a thing is asking for it to run. */
export async function markInstalled(sidecarId: string, completedSteps: string[]): Promise<void> {
await db
.update(sidecarInstalls)
.set({
status: 'installed',
enabled: true,
completedSteps,
lastError: null,
installedAt: new Date(),
updatedAt: new Date(),
})
.where(eq(sidecarInstalls.sidecarId, sidecarId));
}
/**
* Install stopped and cannot continue on its own.
*
* `completedSteps` is still written: what worked stays recorded, so resuming picks up rather than
* repeating. A failure that forgot its progress would re-provision on every retry.
*/
export async function markFailed(sidecarId: string, completedSteps: string[], error: string): Promise<void> {
await db
.update(sidecarInstalls)
.set({ status: 'failed', completedSteps, lastError: error, updatedAt: new Date() })
.where(eq(sidecarInstalls.sidecarId, sidecarId));
}
/**
* Waiting for a human a token only the service's own UI can mint.
*
* Deliberately NOT `failed`. The container is up and healthy and everything so far worked; calling it a
* failure would make a normal install look broken and invite the user to tear down a working service.
* `lastError` carries the instruction instead of an error.
*/
export async function markBlocked(sidecarId: string, completedSteps: string[], reason: string): Promise<void> {
await db
.update(sidecarInstalls)
.set({ status: 'blocked', completedSteps, lastError: reason, updatedAt: new Date() })
.where(eq(sidecarInstalls.sidecarId, sidecarId));
}
/** Enable or disable — the process and its container, without touching anything installed. */
export async function setEnabled(sidecarId: string, enabled: boolean): Promise<void> {
await db
.update(sidecarInstalls)
.set({ enabled, updatedAt: new Date() })
.where(eq(sidecarInstalls.sidecarId, sidecarId));
}
/**
* Forget this install.
*
* Only this row. The sidecar's tables, the service directory and every byte of data under it survive
* see the schema comment. Uninstalling is "stop running this", not "delete my library".
*/
export async function removeInstall(sidecarId: string): Promise<void> {
await db.delete(sidecarInstalls).where(eq(sidecarInstalls.sidecarId, sidecarId));
}
@@ -0,0 +1,83 @@
import { pgTable, serial, text, boolean, timestamp, jsonb, uniqueIndex } from 'drizzle-orm/pg-core';
// What the owner has installed from the app store, and whether it should be running.
//
// ── Why there is no userId ──
//
// A sidecar is ONE process serving the whole machine, so installing one is a server-level act, not a
// per-user one. This is the line that keeps the model coherent once several people share a server:
//
// installed server-level, owner-only — this row
// configured per user — service_connections
//
// Gitea is the worked example. The owner installs it once, and the row here says the process runs; each
// member then holds their own credential in `service_connections`, inheriting the instance URL from the
// owner's row. A member can therefore use a service without being able to install, uninstall or point it
// somewhere else — which is the same split `capabilities/registry.ts` already draws between `app` and
// `admin` kinds.
//
// ── Why `installed` and `enabled` are separate ──
//
// They answer different questions. `installed` means the thing EXISTS: a container was provisioned (or an
// existing instance was named), config was written, schema was applied. `enabled` means the process
// SHOULD BE RUNNING. Disabling is the reversible middle ground the owner asked for — stop the process,
// keep the container, the config, the tables and the data, and start again later at no cost.
//
// Uninstall stops the sidecar and removes the containers. It does NOT remove data, and there is no
// option that does: the service directory and everything under it survives. A user uninstalling a
// sidecar is saying "stop running this", not "delete my photo library", and the two are unrecoverably
// different for Immich and Jellyfin. Reclaiming disk is a separate, deliberate feature with the sizes
// shown — not a checkbox in an uninstall flow.
export const sidecarInstalls = pgTable(
'sidecar_installs',
{
id: serial('id').primaryKey(),
/**
* The catalogue id 'gitea', 'photos'. Text rather than an enum for the same reason
* `service_connections.service` is: adding a sidecar must not be a schema change, and a third-party
* one cannot be in an enum we compile.
*/
sidecarId: text('sidecar_id').notNull(),
/**
* How this install was satisfied, which decides what uninstall has to undo:
*
* 'existing' pointed at an instance the user already runs. We provisioned nothing.
* 'provisioned' we rendered a compose file and started containers. Ours to offer to remove.
* 'config' nothing to reach; credentials only (email, wallet).
*/
mode: text('mode').notNull(),
/**
* 'pending' | 'installing' | 'installed' | 'failed'.
*
* `installing` is a real, persisted state rather than a transient one, because install spans a
* container start and a health wait and can be interrupted by a restart in the middle. A row stuck in
* `installing` is the signal to resume, not evidence of a bug.
*/
status: text('status').notNull().default('pending'),
enabled: boolean('enabled').notNull().default(false),
/**
* Which install steps have completed, by name. This is what makes install RESUMABLE rather than
* merely retryable: re-running picks up after the last completed step instead of provisioning a
* second container or re-writing config that is already right.
*
* The failure mode being designed against is a half-installed service that neither works nor
* uninstalls which is the one users cannot get themselves out of.
*/
completedSteps: jsonb('completed_steps').$type<string[]>().notNull().default([]),
/** Why the last attempt failed, shown in the UI. Cleared on the next successful step. */
lastError: text('last_error'),
/**
* Absolute path to the rendered compose directory, for `mode: 'provisioned'` only.
*
* Stored rather than derived because it is the user's directory and he may move it and because
* uninstall must not guess at a path it is about to run `docker compose down` in.
*/
composeDir: text('compose_dir'),
installedAt: timestamp('installed_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
// One row per sidecar, machine-wide. There is no second install of the same sidecar to disambiguate,
// which is what lets every other column be read without asking "which one".
(t) => [uniqueIndex('uq_sidecar_installs_sidecar').on(t.sidecarId)],
);
+29 -1
View File
@@ -1,4 +1,4 @@
import { pgTable, serial, text, integer, timestamp, index, check } from 'drizzle-orm/pg-core'; import { pgTable, serial, text, integer, timestamp, index, uniqueIndex, check } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm'; import { sql } from 'drizzle-orm';
// The bootstrap account. `id` is a serial starting at 1 and bootstrap is gated on an empty user table, // The bootstrap account. `id` is a serial starting at 1 and bootstrap is gated on an empty user table,
@@ -35,12 +35,40 @@ export const users = pgTable(
role: text('role', { enum: USER_ROLES }).notNull().default('Member'), role: text('role', { enum: USER_ROLES }).notNull().default('Member'),
name: text('name'), name: text('name'),
username: text('username').unique(), username: text('username').unique(),
/**
* The Linux account this platform account runs as, when per-user OS accounts are enabled.
*
* Stored rather than re-derived from `username`. `useradd` can adjust or refuse a name, and a derived
* value would let the platform's idea of who someone is drift from what is actually in `/etc/passwd`
* which, for a field that decides whose uid executes a shell, is not a drift to discover later.
*
* NULL means no OS account: every account created before the feature, every account on a host where
* it is switched off, and the owner (who runs as the service user itself).
* See docs/per-user-linux-accounts.md.
*
* Uniqueness is a `uniqueIndex` below, NOT `.unique()` here. `.unique()` emits a named unique
* CONSTRAINT, and drizzle-kit responds to a new one on a populated table by asking whether to TRUNCATE
* a prompt that cannot be answered in a non-interactive `db:push` and which stops the whole push.
* Hit and reverted on 2026-08-11; same trap as the composite keys in databases/CLAUDE.md.
*/
osUser: text('os_user'),
/**
* The PUBLIC half of the outbound SSH key generated in this account's home.
*
* Stored so the owner can retrieve it later it has to be pasted into the member's Gitea account, and
* the home is 700 so nothing can read it back off disk without root. Public by definition; the private
* half never leaves the machine and is never in this database.
*/
osSshPublicKey: text('os_ssh_public_key'),
avatar: text('avatar'), avatar: text('avatar'),
passwordChangedAt: timestamp('password_changed_at', { withTimezone: true }), passwordChangedAt: timestamp('password_changed_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
}, },
(table) => [ (table) => [
// Two accounts must not share one Linux user — that would make "whose uid is this" ambiguous for a
// shell. An index rather than a constraint: see the note on the column.
uniqueIndex('uq_users_os_user').on(table.osUser),
// The owner cannot be demoted. Enforced here rather than in application code because the whole // The owner cannot be demoted. Enforced here rather than in application code because the whole
// point is that it holds "whatever happens" — a stray UPDATE, a migration script, someone at a psql // point is that it holds "whatever happens" — a stray UPDATE, a migration script, someone at a psql
// prompt. Postgres rejects the write; there is no path around it. // prompt. Postgres rejects the write; there is no path around it.
@@ -1,3 +1,4 @@
export * from './app-store';
export * from './agent-panels'; export * from './agent-panels';
export * from './api-keys'; export * from './api-keys';
export * from './auth'; export * from './auth';
+52 -1
View File
@@ -3,9 +3,11 @@ import type { ServerWebSocket } from 'bun';
import { serve } from 'bun'; import { serve } from 'bun';
import { honoServer, PROTECTED_API_PREFIXES, UNPROTECTED_API_PREFIXES } from './servers/hono'; import { honoServer, PROTECTED_API_PREFIXES, UNPROTECTED_API_PREFIXES } from './servers/hono';
import { assertCapabilityTotality } from './servers/capabilities/totality'; import { assertCapabilityTotality } from './servers/capabilities/totality';
import { assertSecretsClosed } from './servers/os-user';
import { resolveHomeDir } from './servers/user-home';
import { resolveAuthToken } from './servers/auth-token'; import { resolveAuthToken } from './servers/auth-token';
import { isWsProviderAllowed } from './servers/capabilities/authorize'; import { isWsProviderAllowed } from './servers/capabilities/authorize';
import { isTokenBlacklisted } from 'officerdb'; import { isTokenBlacklisted, getUserById } from 'officerdb';
import { terminalWebsocket } from './servers/api/terminal/websocket'; import { terminalWebsocket } from './servers/api/terminal/websocket';
import { chatWebsocket } from './servers/api/chat/websocket'; import { chatWebsocket } from './servers/api/chat/websocket';
import { taskRunnerWebsocket } from './servers/api/tasks/task-executor'; import { taskRunnerWebsocket } from './servers/api/tasks/task-executor';
@@ -149,6 +151,11 @@ assertCapabilityTotality({
wsProviders: Object.keys(handlers), wsProviders: Object.keys(handlers),
}); });
// And, when members have real Linux accounts, that they cannot read the credentials that would make those
// accounts pointless. Also before serve(), also throws: a shell handed out next to a world-readable
// JWT_SECRET is worse than no isolation, because the model looks intact. No-op while the feature is off.
await assertSecretsClosed(process.cwd());
async function upgradeWs( async function upgradeWs(
req: Request, req: Request,
server: any, server: any,
@@ -187,6 +194,36 @@ async function upgradeWs(
const command = url.searchParams.get('command') ?? undefined; const command = url.searchParams.get('command') ?? undefined;
const cols = url.searchParams.get('cols') ? Number(url.searchParams.get('cols')) : undefined; const cols = url.searchParams.get('cols') ? Number(url.searchParams.get('cols')) : undefined;
const rows = url.searchParams.get('rows') ? Number(url.searchParams.get('rows')) : undefined; const rows = url.searchParams.get('rows') ? Number(url.searchParams.get('rows')) : undefined;
// ── Whose shell is this ──
//
// The terminal bridge forwards this query string to the pty sidecar untouched, and the sidecar starts a
// shell from what it finds there. So `osUser` and `home` are resolved HERE, from the authenticated
// account, and any values the browser sent are deleted first. Trusting the client for either would let a
// member ask for the owner's uid in a query parameter.
//
// Absent for the owner: no `osUser` means the sidecar runs the shell as itself, which is the behaviour
// this has always had.
url.searchParams.delete('osUser');
url.searchParams.delete('home');
// The chat socket's owner-only refusal was removed on 2026-08-12, with `api/chat/chat.ts`'s in the same
// commit — they were always one guard in two places. A member's turn now runs as their own Linux account
// with their own credential and their own transcripts; the capability check above is what gates it.
if (provider === 'terminal') {
const resolved = await resolveHomeDir(user.id);
if (!resolved.ok) return new Response('Forbidden', { status: 403 });
if (!resolved.isOwner) {
const dbUser = await getUserById(user.id);
// A confined capability is only granted to an account with an OS user, so this should not happen —
// and if it ever does, refusing beats opening the owner's shell.
if (!dbUser?.osUser) return new Response('Forbidden', { status: 403 });
url.searchParams.set('osUser', dbUser.osUser);
url.searchParams.set('home', resolved.home);
}
}
const ok = server.upgrade(req, { const ok = server.upgrade(req, {
data: { data: {
userId: user.id, userId: user.id,
@@ -221,6 +258,20 @@ const server = serve({
const file = Bun.file(`public${new URL(req.url).pathname}`); const file = Bun.file(`public${new URL(req.url).pathname}`);
return new Response(file); return new Response(file);
}, },
// Icons and assets belonging to installed sidecars, copied to public/plugins/<id>/ by the installer.
//
// Served by this dynamic route rather than by `publicRoutes` above, which is a snapshot taken by
// globbing ./public at BOOT. A plugin installed while the server is running would not be in that map,
// so its icon would 404 until the next restart — and "install it, then restart the server to see the
// icon" is not an install.
'/plugins/*': async (req) => {
const file = Bun.file(`public${new URL(req.url).pathname}`);
// 404 rather than letting a missing file surface as a 500. An icon that has not been published
// yet is an ordinary state — the plugin is not installed — and a 500 would put a red line in the
// log for every dock render on a fresh machine.
if (!(await file.exists())) return new Response('Not found', { status: 404 });
return new Response(file);
},
// Vaultwarden notifications hub: upgrade the WebSocket here (proxied to upstream by vaultWebsocket); // Vaultwarden notifications hub: upgrade the WebSocket here (proxied to upstream by vaultWebsocket);
// everything else on this path (SignalR long-poll negotiate/poll) falls through to the HTTP proxy. // everything else on this path (SignalR long-poll negotiate/poll) falls through to the HTTP proxy.
'/api/vault/notifications/*': (req, server) => { '/api/vault/notifications/*': (req, server) => {
+19 -7
View File
@@ -49,18 +49,30 @@ export const userMiddleware: MiddlewareHandler = async function (ctx, next) {
if (await isTokenBlacklisted(user.jti)) throw errors.UNAUTHORIZED(); if (await isTokenBlacklisted(user.jti)) throw errors.UNAUTHORIZED();
} }
// Check if token was issued before password change // The account still has to exist, and still has to be allowed in.
if (user.iat && user.id) { //
// This lookup used to happen only for the password-change comparison below, and its result was read
// as `dbUser?.passwordChangedAt` — so a DELETED account fell straight through the optional chain and
// kept working on a token that is still cryptographically valid, for up to the full 30 days. Observed
// 2026-08-11: an account deleted from the dashboard survived a page refresh in another window.
//
// `status` is the same shape of hole. signin.ts refuses anything that is not 'Active', but nothing
// re-checked it afterwards, so marking someone Blocked or Banned did not end the session they already
// had — which is precisely when you would be doing it.
//
// Re-read per request rather than trusted as a claim, for the reason the role is not a claim either:
// a revocation has to take effect on the next request, not at next sign-in.
if (user.id) {
const dbUser = await getUserById(user.id); const dbUser = await getUserById(user.id);
if (dbUser?.passwordChangedAt) { if (!dbUser) throw errors.UNAUTHORIZED();
// iat is in seconds, passwordChangedAt is a Date if (dbUser.status !== 'Active') throw errors.UNAUTHORIZED();
const tokenIssuedAt = user.iat * 1000;
if (tokenIssuedAt < dbUser.passwordChangedAt.getTime()) { // Token issued before the password changed → refuse. iat is seconds, passwordChangedAt is a Date.
if (user.iat && dbUser.passwordChangedAt && user.iat * 1000 < dbUser.passwordChangedAt.getTime()) {
throw errors.UNAUTHORIZED(); throw errors.UNAUTHORIZED();
} }
} }
} }
}
ctx.set('user', user); ctx.set('user', user);
return next(); return next();
+2 -1
View File
@@ -2,6 +2,7 @@ import { getUserById, markAgentPanelIntroduced, type AgentPanel } from 'officerd
import * as sidecar from '@@/sidecar-registry'; import * as sidecar from '@@/sidecar-registry';
import { resolveBaseCwd } from '../chat/websocket'; import { resolveBaseCwd } from '../chat/websocket';
import { logger } from '../chat/logger'; import { logger } from '../chat/logger';
import { getOwnerHomeDir } from '@@/data-path';
/** /**
* Push a turn into an agent panel's session with or without a browser attached. * Push a turn into an agent panel's session with or without a browser attached.
@@ -33,7 +34,7 @@ export async function deliverToAgentPanel(target: AgentPanel, prompt: string): P
username: user.name ?? user.email, username: user.name ?? user.email,
prompt, prompt,
sessionKey: target.sessionKey, sessionKey: target.sessionKey,
cwd: resolveBaseCwd(user.email, target.cwd ?? undefined), cwd: resolveBaseCwd(getOwnerHomeDir(user.email), target.cwd ?? undefined),
// Deliberately no `model`: a live session ignores it anyway, and a resumed one keeps whatever the // Deliberately no `model`: a live session ignores it anyway, and a resumed one keeps whatever the
// panel started with. Passing one here would only look like it worked. // panel started with. Passing one here would only look like it worked.
durable: true, durable: true,
+79
View File
@@ -0,0 +1,79 @@
import { getUserById } from 'officerdb';
import { claudeLoginState } from '@@/os-user-claude';
import { resolveHomeDir } from '@@/user-home';
import { createRouter } from '../../create-router';
// Can this account actually run an agent, and if not, what does it need to do about it?
//
// ── Why this is not in `api/chat` ──
//
// It would be the obvious home, and it cannot go there: `chat.ts:49` refuses every non-owner wholesale, so an
// endpoint on that router could not be read by the accounts that most need it. The refusal is right — reads
// there leak the owner's project directory names — but it means the answer to "why is my agent not working"
// has to live somewhere a member can reach.
//
// So this is its own router under the `chat` capability. Same grant, no owner gate, and nothing here reports
// on anyone but the caller: two booleans about their own home. Denying it would not restrict an account, it
// would just replace an explanation with a silence.
//
// ── Why the platform cannot fix `loggedIn` for them ──
//
// `claude` authenticates interactively against an Anthropic account, so logging in is the member's own act.
// The alternative — pointing them at the owner's credential proxy — spends the owner's subscription on their
// turns, which is the thing the whole per-user design exists to avoid. What the UI does with a `false` here is
// render a terminal and tell them to run `claude` once themselves.
export const agentStatusRouter = createRouter();
type AgentStatus = {
/** Their own `claude` is installed in their home. */
installed: boolean;
/** They have logged in, so a turn can actually run. */
loggedIn: boolean;
/** True for the owner, whose agent has always worked and who needs no instructions. */
isOwner: boolean;
/** Null unless something is missing — the one thing to tell them, already resolved to their case. */
instruction: string | null;
};
agentStatusRouter.get('/', async (ctx) => {
const user = ctx.get('user');
const resolved = await resolveHomeDir(user.id);
if (!resolved.ok) {
// No Linux account means nothing is confined and nothing can run — the same refusal the file browser
// gives, reported rather than thrown because this endpoint exists to explain, not to gate.
return ctx.json<AgentStatus>({
installed: false,
loggedIn: false,
isOwner: false,
instruction: resolved.reason,
});
}
// The owner runs in their real login home with the credential the proxy already holds. There is no member
// account to `runAs`, and nothing to instruct.
if (resolved.isOwner) {
return ctx.json<AgentStatus>({ installed: true, loggedIn: true, isOwner: true, instruction: null });
}
const row = await getUserById(user.id);
if (!row?.osUser) {
return ctx.json<AgentStatus>({
installed: false,
loggedIn: false,
isOwner: false,
instruction: 'This account has no Linux user on this machine yet — ask the server owner to provision it.',
});
}
const state = await claudeLoginState({ email: row.email, osUser: row.osUser });
const instruction = !state.installed
? 'Claude is not installed in your home yet — ask the server owner to reprovision your account.'
: !state.loggedIn
? 'Open a terminal and run `claude` once to sign in with your own Anthropic account. It stays signed in.'
: null;
return ctx.json<AgentStatus>({ ...state, isOwner: false, instruction });
});
+9 -3
View File
@@ -87,7 +87,7 @@ export function buildAgentPrompt(agent: AgentRecord, inputs: Record<string, unkn
* which is fine: while a run is live you find it as the newest entry in the agent's project group. * which is fine: while a run is live you find it as the newest entry in the agent's project group.
*/ */
function titleRun( function titleRun(
email: string, who: { email: string; home: string },
cwd: string, cwd: string,
claudeSessionId: string, claudeSessionId: string,
agentName: string, agentName: string,
@@ -99,7 +99,7 @@ function titleRun(
const title = [agentName, subject, when].filter(Boolean).join(' · '); const title = [agentName, subject, when].filter(Boolean).join(' · ');
try { try {
if (!renameClaudeSession(email, cwd, claudeSessionId, title)) { if (!renameClaudeSession(who, cwd, claudeSessionId, title)) {
logger.warn('Could not title agent run — transcript not found', { claudeSessionId, cwd }); logger.warn('Could not title agent run — transcript not found', { claudeSessionId, cwd });
} }
} catch (err) { } catch (err) {
@@ -171,7 +171,13 @@ export async function startAgentRun(params: StartAgentRunParams): Promise<StartA
run.finishedAt = Date.now(); run.finishedAt = Date.now();
if (msg.claudeSessionId) { if (msg.claudeSessionId) {
run.claudeSessionId = msg.claudeSessionId; run.claudeSessionId = msg.claudeSessionId;
titleRun(params.user.email, cwd, msg.claudeSessionId, agent.name || agent.dirName, inputs); titleRun(
{ email: params.user.email, home: homeDir },
cwd,
msg.claudeSessionId,
agent.name || agent.dirName,
inputs,
);
} }
} else if (msg.type === 'error') { } else if (msg.type === 'error') {
run.status = 'failed'; run.status = 'failed';
+85
View File
@@ -0,0 +1,85 @@
import { createRouter } from '../../create-router';
import * as errors from '../../custom-errors';
import { isSuperAdmin } from '../../super-admin';
import { listStore, install, setEnabled, uninstall } from '../../app-store/service';
import { byId, type InstallMode } from '../../app-store/catalogue';
// /api/app-store — what can be installed, what is installed, and the four verbs that change it.
//
// ── Owner only, explicitly ──
//
// Installing a sidecar starts a process on the machine, and provisioning one starts containers. That is
// an administrative act however many members share the server, so this router gates on the owner in its
// own right rather than relying on the capability layer alone. `server-admin` already covers it, and
// this is the belt to that braces — the same shape `/api/vault` uses, and for the same reason: a
// mistake here is not a leak of data, it is arbitrary process control.
//
// The per-user half lives elsewhere: `service_connections` is where a member's own credential goes, and
// members.ts is how they get one.
export const appStoreRouter = createRouter();
appStoreRouter.use(async (ctx, next) => {
if (!(await isSuperAdmin(ctx.get('user')))) throw errors.FORBIDDEN('The app store is owner-only');
return next();
});
/** GET /api/app-store — the catalogue joined to what has happened to each entry. */
appStoreRouter.get('/', async (ctx) => {
return ctx.json({ items: await listStore() });
});
/**
* POST /api/app-store/:id/install install, or resume one that stopped.
*
* Resume is the same call deliberately: pressing the button after a failure and pressing it after
* supplying the API key it was waiting for are one action to the user. What re-runs is decided by the
* steps already recorded, not by which endpoint was hit.
*
* Answers with the outcome rather than a bare 200 `blocked` is a normal result that the UI has to
* render differently from success, and flattening it to "ok" would lose the reason and the link.
*/
appStoreRouter.post('/:id/install', async (ctx) => {
const id = ctx.req.param('id');
const entry = byId(id);
if (!entry) throw errors.NOT_FOUND(`Unknown sidecar: ${id}`);
const body = (ctx.get('body') ?? {}) as { mode?: string; values?: Record<string, string> };
const mode = body.mode as InstallMode | undefined;
if (!mode) throw errors.BAD_REQUEST('mode is required');
if (!entry.modes.includes(mode)) {
throw errors.BAD_REQUEST(`${entry.label} cannot be installed as '${mode}'`);
}
// The install log is collected rather than streamed for now. Streaming it into a terminal panel is
// the intended shape — the lines are already produced one at a time — and needs a channel this route
// does not have yet.
const lines: string[] = [];
const outcome = await install({ sidecarId: id, mode, values: body.values ?? {}, log: (l) => lines.push(l) });
return ctx.json({ outcome, log: lines });
});
/** POST /api/app-store/:id/enable — start the sidecar (and, later, its container). */
appStoreRouter.post('/:id/enable', async (ctx) => {
await setEnabled(ctx.req.param('id'), true);
return ctx.json({ ok: true });
});
/** POST /api/app-store/:id/disable — stop it, keeping everything installed. */
appStoreRouter.post('/:id/disable', async (ctx) => {
await setEnabled(ctx.req.param('id'), false);
return ctx.json({ ok: true });
});
/**
* POST /api/app-store/:id/uninstall stop running this.
*
* A POST rather than a DELETE, because it is not a deletion: the sidecar's tables and the service
* directory survive it. Calling it DELETE would suggest otherwise to the next person reading the route
* table.
*/
appStoreRouter.post('/:id/uninstall', async (ctx) => {
await uninstall(ctx.req.param('id'));
return ctx.json({ ok: true });
});
+19 -1
View File
@@ -1,5 +1,6 @@
import type { Handler } from 'hono'; import type { Handler } from 'hono';
import { getUserCount, createUser } from 'officerdb'; import { getUserCount, createUser, replaceRoleGrants, USER_ROLES } from 'officerdb';
import { DEFAULT_ROLE_CAPABILITIES } from '@@/capabilities/registry';
import argon2 from 'argon2'; import argon2 from 'argon2';
import * as errors from '@@/custom-errors'; import * as errors from '@@/custom-errors';
import { rememberUser } from '@@/_middlewares'; import { rememberUser } from '@@/_middlewares';
@@ -44,6 +45,23 @@ export const bootstrapHandler: Handler = async function (ctx) {
role: 'Super Admin', role: 'Super Admin',
}); });
// Every other role starts with the baseline: terminal, chat and files at write. Done here because
// bootstrap is the one moment that happens exactly once per install, so seeding cannot fight a later
// revocation — take one of these away and nothing puts it back.
//
// Non-fatal. An owner who exists but whose roles hold nothing is a working server with a one-click fix;
// failing bootstrap over it would leave a platform with no account at all.
try {
for (const role of USER_ROLES.filter((r) => r !== 'Super Admin')) {
await replaceRoleGrants(
role,
DEFAULT_ROLE_CAPABILITIES.map((capability) => ({ capability, level: 'write' as const })),
);
}
} catch (ex) {
console.warn('[bootstrap] could not seed default role capabilities', ex);
}
// The launch-time snapshot was taken while the user table was still empty. Without this the owner's // The launch-time snapshot was taken while the user table was still empty. Without this the owner's
// very first sign-in would be filed as an unknown identity. // very first sign-in would be filed as an unknown identity.
rememberUser(user); rememberUser(user);
+66 -25
View File
@@ -1,9 +1,9 @@
import type { Context } from 'hono'; import type { Context } from 'hono';
import { createRouter } from '../../create-router'; import { createRouter } from '../../create-router';
import * as errors from '@@/custom-errors';
import * as sidecar from '@@/sidecar-registry'; import * as sidecar from '@@/sidecar-registry';
import { getUserSettings } from 'officerdb'; import { getUserSettings } from 'officerdb';
import { import {
getGeneralChatSessionsCwd,
listClaudePwds, listClaudePwds,
listClaudeSessions, listClaudeSessions,
loadClaudeSession, loadClaudeSession,
@@ -24,30 +24,69 @@ import {
import { getOpenCodePrompt, getOpenCodeSession } from './opencode/state'; import { getOpenCodePrompt, getOpenCodeSession } from './opencode/state';
import { listChatModels } from './list-models'; import { listChatModels } from './list-models';
import { logger } from './logger'; import { logger } from './logger';
import { resolveHomeDir } from '@@/user-home';
import type { ChatIdentity } from './claude-sessions';
import { readSttConfig } from '../server-settings/stt'; import { readSttConfig } from '../server-settings/stt';
/**
* Whose transcripts a request may read.
*
* The home comes from `resolveHomeDir`, never from `getOwnerHomeDir` that one ignores its argument whenever
* HOME_DIR is set, which is how every read in this router used to resolve to the owner's `~/.claude` no matter
* who asked. Throws rather than falling back, for the same reason `resolveTurnIdentity` refuses: there is no
* safe home to substitute, and the owner's is the one wrong answer.
*
* Unreachable by a member today the router refuses non-owners above so this is the path being made correct
* before it is opened, not a live fix.
*/
async function chatIdentity(user: { id: number; email: string }): Promise<ChatIdentity> {
const resolved = await resolveHomeDir(user.id);
if (!resolved.ok) throw errors.FORBIDDEN(resolved.reason);
return { email: user.email, home: resolved.home };
}
import { transcribeAudio } from '../stt/transcribe'; import { transcribeAudio } from '../stt/transcribe';
import { registerAgentPanelRoutes } from './agent-panels-routes'; import { registerAgentPanelRoutes } from './agent-panels-routes';
export const chatRouter = createRouter(); export const chatRouter = createRouter();
// ── Chat reached members on 2026-08-12 ──
//
// A wholesale `isSuperAdmin` refusal stood here from the day `chat` became grantable until tonight. It said
// the machinery was not ready, and it was right: a turn spawned `claude` as the OWNER, and every transcript
// path resolved through the owner's home, so a granted member would have read the owner's sessions and run an
// agent as them.
//
// What replaced it, rather than what deleted it:
//
// - the turn runs as the member — `spawnClaudeAsMember` through `sudo setpriv`, proven against a real
// account by `spawn-as-member.live.test.ts` reading file ownership rather than trusting the process
// - the credential is theirs — `--reset-env` plus an allowlist, so the owner's proxy variables cannot cross
// - the transcripts are theirs — `ChatIdentity` carries a home resolved from `resolveHomeDir`, and this file
// no longer knows how to invent one
// - the sessions are theirs — every session records its owner, and all six sidecar commands refuse a
// mismatch rather than acting on whoever matched
//
// Each of those is a separate commit with its own reasoning, and each was found wanting at least once by a
// reviewer who had not written it. If you are reverting this, revert to a refusal — not to a narrower one.
// The working directory a request operates on: an explicit ?cwd= (a chosen pwd), else the default // The working directory a request operates on: an explicit ?cwd= (a chosen pwd), else the default
// general_chat_sessions dir. Claude groups sessions by cwd, so this selects which project group we read. // caller's own home. Claude groups sessions by cwd, so this selects which project group we read.
// OpenCode runs on one fixed serve, but each session records the directory its turn ran in, so cwd // OpenCode runs on one fixed serve, but each session records the directory its turn ran in, so cwd
// selects there too. // selects there too.
const cwdOf = (ctx: Context, email: string): string => ctx.req.query('cwd')?.trim() || getGeneralChatSessionsCwd(email); const cwdOf = (ctx: Context, home: string): string => ctx.req.query('cwd')?.trim() || home;
// GET /chat/pwds — the default /chat dir plus every directory that already has Claude sessions. // GET /chat/pwds — the default /chat dir plus every directory that already has Claude sessions.
chatRouter.get('/pwds', (ctx) => { chatRouter.get('/pwds', async (ctx) => {
const email = ctx.get('user').email; const who = await chatIdentity(ctx.get('user'));
return ctx.json({ pwds: listClaudePwds(email), default: getGeneralChatSessionsCwd(email) }); return ctx.json({ pwds: listClaudePwds(who), default: who.home });
}); });
// GET /chat/sessions[?cwd=] — conversations for a working directory, merged across both harnesses // GET /chat/sessions[?cwd=] — conversations for a working directory, merged across both harnesses
// (Claude transcripts + OpenCode's session store), newest first. // (Claude transcripts + OpenCode's session store), newest first.
chatRouter.get('/sessions', async (ctx) => { chatRouter.get('/sessions', async (ctx) => {
const email = ctx.get('user').email; const who = await chatIdentity(ctx.get('user'));
const cwd = cwdOf(ctx, email); const cwd = cwdOf(ctx, who.home);
const claude = listClaudeSessions(email, cwd).map((s) => ({ ...s, harness: 'claude' as const })); const claude = listClaudeSessions(who, cwd).map((s) => ({ ...s, harness: 'claude' as const }));
const opencode = await listOpenCodeSessions(cwd); const opencode = await listOpenCodeSessions(cwd);
const sessions = [...claude, ...opencode].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); const sessions = [...claude, ...opencode].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
return ctx.json({ sessions }); return ctx.json({ sessions });
@@ -60,14 +99,14 @@ chatRouter.get('/sessions', async (ctx) => {
// response carries `total` (full length) and `offset` (absolute index of messages[0]) so the client knows // response carries `total` (full length) and `offset` (absolute index of messages[0]) so the client knows
// where the window sits and whether older messages remain above it. // where the window sits and whether older messages remain above it.
chatRouter.get('/sessions/:id', async (ctx) => { chatRouter.get('/sessions/:id', async (ctx) => {
const email = ctx.get('user').email; const who = await chatIdentity(ctx.get('user'));
const id = ctx.req.param('id'); const id = ctx.req.param('id');
const cwd = cwdOf(ctx, email); const cwd = cwdOf(ctx, who.home);
// Fall back to a by-id scan when the (default) cwd doesn't hold it — a fresh /chat/<id> deep-link/refresh // Fall back to a by-id scan when the (default) cwd doesn't hold it — a fresh /chat/<id> deep-link/refresh
// doesn't know the session's cwd. The returned detail carries the real cwd for the client to scope the UI. // doesn't know the session's cwd. The returned detail carries the real cwd for the client to scope the UI.
const detail = isOpenCodeSessionId(id) const detail = isOpenCodeSessionId(id)
? await loadOpenCodeSession(id) ? await loadOpenCodeSession(id)
: (loadClaudeSession(email, cwd, id) ?? loadClaudeSessionById(email, id)); : (loadClaudeSession(who, cwd, id) ?? loadClaudeSessionById(who, id));
if (!detail) return ctx.text('Not found', 404); if (!detail) return ctx.text('Not found', 404);
const total = detail.messages.length; const total = detail.messages.length;
@@ -83,7 +122,7 @@ chatRouter.get('/sessions/:id', async (ctx) => {
// `detail.cwd` — the transcript's own directory — not the requested `cwd`, which on a deep link is // `detail.cwd` — the transcript's own directory — not the requested `cwd`, which on a deep link is
// still the default group and holds none of this session's neighbours. OpenCode has no chains of its // still the default group and holds none of this session's neighbours. OpenCode has no chains of its
// own, so it gets neither rather than a fabricated answer. // own, so it gets neither rather than a fabricated answer.
const context = isOpenCodeSessionId(id) ? null : claudeSessionContext(email, detail.cwd, id); const context = isOpenCodeSessionId(id) ? null : claudeSessionContext(who, detail.cwd, id);
return ctx.json({ return ctx.json({
...detail, ...detail,
@@ -106,11 +145,13 @@ chatRouter.get('/sessions/:id', async (ctx) => {
// Titles are resolved here rather than in the client, which can only name the sessions in the group it // Titles are resolved here rather than in the client, which can only name the sessions in the group it
// happens to be browsing — which is how the list ended up showing raw ids for anything running elsewhere. // happens to be browsing — which is how the list ended up showing raw ids for anything running elsewhere.
chatRouter.get('/live', async (ctx) => { chatRouter.get('/live', async (ctx) => {
const email = ctx.get('user').email; const user = ctx.get('user');
const who = await chatIdentity(user);
const email = who.email;
// Both harnesses, asked in parallel. Either failing contributes nothing rather than failing the panel: // Both harnesses, asked in parallel. Either failing contributes nothing rather than failing the panel:
// both registry calls swallow their errors and return []. // both registry calls swallow their errors and return [].
const [live, liveOpenCode] = await Promise.all([ const [live, liveOpenCode] = await Promise.all([
sidecar.listLiveClaudeSessions(), sidecar.listLiveClaudeSessions(user.id),
sidecar.listLiveOpenCodeSessions(), sidecar.listLiveOpenCodeSessions(),
]); ]);
const sessions = live.map((session) => { const sessions = live.map((session) => {
@@ -118,7 +159,7 @@ chatRouter.get('/live', async (ctx) => {
// is named after Claude's. Null until the first turn reports one, which is a conversation that has // is named after Claude's. Null until the first turn reports one, which is a conversation that has
// genuinely not been written yet. // genuinely not been written yet.
const transcriptId = session.claudeSessionId; const transcriptId = session.claudeSessionId;
const resolved = transcriptId && !isOpenCodeSessionId(transcriptId) ? liveSessionTitle(email, transcriptId) : null; const resolved = transcriptId && !isOpenCodeSessionId(transcriptId) ? liveSessionTitle(who, transcriptId) : null;
return { ...session, harness: 'claude' as const, title: resolved?.title ?? null, cwd: resolved?.cwd ?? null }; return { ...session, harness: 'claude' as const, title: resolved?.title ?? null, cwd: resolved?.cwd ?? null };
}); });
@@ -168,24 +209,24 @@ chatRouter.get('/live', async (ctx) => {
// Claude `/clear` chain that is every part of it: the list shows the chain as one conversation, so // Claude `/clear` chain that is every part of it: the list shows the chain as one conversation, so
// deleting it deletes one conversation. // deleting it deletes one conversation.
chatRouter.delete('/sessions/:id', async (ctx) => { chatRouter.delete('/sessions/:id', async (ctx) => {
const email = ctx.get('user').email; const who = await chatIdentity(ctx.get('user'));
const id = ctx.req.param('id'); const id = ctx.req.param('id');
const cwd = cwdOf(ctx, email); const cwd = cwdOf(ctx, who.home);
const ok = isOpenCodeSessionId(id) ? await deleteOpenCodeSession(id) : deleteClaudeSession(email, cwd, id); const ok = isOpenCodeSessionId(id) ? await deleteOpenCodeSession(id) : deleteClaudeSession(who, cwd, id);
if (!ok) return ctx.text('Not found', 404); if (!ok) return ctx.text('Not found', 404);
return ctx.json({ ok: true }); return ctx.json({ ok: true });
}); });
// PATCH /chat/sessions/:id/title[?cwd=] — rename in the owning harness's store. // PATCH /chat/sessions/:id/title[?cwd=] — rename in the owning harness's store.
chatRouter.patch('/sessions/:id/title', async (ctx) => { chatRouter.patch('/sessions/:id/title', async (ctx) => {
const email = ctx.get('user').email; const who = await chatIdentity(ctx.get('user'));
const id = ctx.req.param('id'); const id = ctx.req.param('id');
const cwd = cwdOf(ctx, email); const cwd = cwdOf(ctx, who.home);
const { title } = await ctx.req.json<{ title?: string }>(); const { title } = await ctx.req.json<{ title?: string }>();
if (!title?.trim()) return ctx.text('title is required', 400); if (!title?.trim()) return ctx.text('title is required', 400);
const ok = isOpenCodeSessionId(id) const ok = isOpenCodeSessionId(id)
? await renameOpenCodeSession(id, title.trim()) ? await renameOpenCodeSession(id, title.trim())
: renameClaudeSession(email, cwd, id, title.trim()); : renameClaudeSession(who, cwd, id, title.trim());
if (!ok) return ctx.text('Not found', 404); if (!ok) return ctx.text('Not found', 404);
return ctx.json({ ok: true }); return ctx.json({ ok: true });
}); });
@@ -196,9 +237,9 @@ chatRouter.patch('/sessions/:id/title', async (ctx) => {
// A task that has not written anything yet answers 200 with `{ kind: 'pending' }`, not 404. The tray asks // A task that has not written anything yet answers 200 with `{ kind: 'pending' }`, not 404. The tray asks
// the moment `task:started` arrives, which is routinely before the file exists, and a 404 there would be // the moment `task:started` arrives, which is routinely before the file exists, and a 404 there would be
// an error state for the most ordinary thing that can happen. // an error state for the most ordinary thing that can happen.
chatRouter.get('/tasks/:id', (ctx) => { chatRouter.get('/tasks/:id', async (ctx) => {
const email = ctx.get('user').email; const who = await chatIdentity(ctx.get('user'));
const detail = loadBackgroundTask(email, ctx.req.param('id')); const detail = loadBackgroundTask(who, ctx.req.param('id'));
return ctx.json(detail ?? { kind: 'pending' }); return ctx.json(detail ?? { kind: 'pending' });
}); });
+61 -52
View File
@@ -18,23 +18,32 @@ import { DATA_PATH } from '../../data-path';
// The `claude` CLI persists every session as a JSONL transcript at // The `claude` CLI persists every session as a JSONL transcript at
// $HOME/.claude/projects/<slug>/<session-uuid>.jsonl // $HOME/.claude/projects/<slug>/<session-uuid>.jsonl
// where <slug> is the working directory with every non-alphanumeric char replaced by '-'. // where <slug> is the working directory with every non-alphanumeric char replaced by '-'.
// Single-user platform: Claude runs with no isolation — HOME is the real home // Claude runs with no isolation for the OWNER — HOME is their real home — so its transcripts are the same
// (HOME_DIR) — so its transcripts are the same store the terminal `claude` uses. We never keep our // store their terminal `claude` uses. We never keep our own copy; Claude's files are authoritative.
// own copy; Claude's files are authoritative. //
// ── Why this takes a home instead of an email ──
const claudeHome = (email: string): string => process.env.HOME_DIR ?? join(DATA_PATH, email, 'home'); //
// It used to be `process.env.HOME_DIR ?? join(DATA_PATH, email, 'home')`, which discards its argument whenever
/** Dedicated working directory for /chat sessions, so they form their own Claude "project" group. */ // HOME_DIR is set — which is always, on a real install. Every read therefore resolved to the OWNER'S
export const getGeneralChatSessionsCwd = (email: string): string => join(DATA_PATH, email, 'general_chat_sessions'); // transcripts regardless of who was asking, and the comment above it said "single-user platform" as though
// that were a property rather than an assumption. A member reaching these functions would have been handed the
/** Same, but create the directory if it doesn't exist (call before spawning a /chat session). */ // owner's conversation list.
export const ensureGeneralChatSessionsCwd = (email: string): string => { //
const dir = getGeneralChatSessionsCwd(email); // So the home arrives resolved, from `resolveHomeDir(userId)`, and this file has no way to invent one.
mkdirSync(dir, { recursive: true }); //
return dir; // The email travels alongside it rather than being derived from it, because the two answer different
// questions: the email says WHO, the home says WHERE. They were briefly conflated in the other direction —
// `general_chat_sessions` was an email-derived path under DATA_PATH used as a chat's working directory, and
// because `confineUserTree` makes every sibling of a home the platform's at 0700, a member's turn started in
// a directory it could not enter. That default is now the caller's own home; the pairing survives because the
// distinction it encodes is real.
export type ChatIdentity = {
email: string;
/** From `resolveHomeDir`. Never `getOwnerHomeDir`, which ignores its argument. */
home: string;
}; };
const claudeProjectsDir = (email: string): string => join(claudeHome(email), '.claude', 'projects'); const claudeProjectsDir = (home: string): string => join(home, '.claude', 'projects');
/** Claude's folder name for a working directory. */ /** Claude's folder name for a working directory. */
export const projectSlug = (cwd: string): string => cwd.replace(/[^a-zA-Z0-9]/g, '-'); export const projectSlug = (cwd: string): string => cwd.replace(/[^a-zA-Z0-9]/g, '-');
@@ -506,15 +515,15 @@ function parseClaudeTranscript(filePath: string, sessionId: string, fallbackCwd
* Pagination needs nothing: the client asks for index windows into whatever the server calls the * Pagination needs nothing: the client asks for index windows into whatever the server calls the
* transcript, so a longer one simply pages further back. * transcript, so a longer one simply pages further back.
*/ */
function loadChainTranscript(email: string, detail: ClaudeSessionDetail): ClaudeSessionDetail { function loadChainTranscript(who: ChatIdentity, detail: ClaudeSessionDetail): ClaudeSessionDetail {
const parts = (() => { const parts = (() => {
const group = scanGroup(email, detail.cwd); const group = scanGroup(who, detail.cwd);
const head = group.find((session) => session.id === detail.id); const head = group.find((session) => session.id === detail.id);
return head ? chainOf(head, new Map(group.map((session) => [session.id, session]))) : []; return head ? chainOf(head, new Map(group.map((session) => [session.id, session]))) : [];
})(); })();
if (parts.length < 2) return detail; if (parts.length < 2) return detail;
const dir = join(claudeProjectsDir(email), projectSlug(detail.cwd)); const dir = join(claudeProjectsDir(who.home), projectSlug(detail.cwd));
const earlier: ClaudeChatMessage[] = []; const earlier: ClaudeChatMessage[] = [];
for (const part of parts.slice(0, -1)) { for (const part of parts.slice(0, -1)) {
const segment = parseClaudeTranscript(join(dir, `${part.id}.jsonl`), part.id, detail.cwd); const segment = parseClaudeTranscript(join(dir, `${part.id}.jsonl`), part.id, detail.cwd);
@@ -526,20 +535,20 @@ function loadChainTranscript(email: string, detail: ClaudeSessionDetail): Claude
} }
/** Load a session when its cwd (project group) is known. */ /** Load a session when its cwd (project group) is known. */
export function loadClaudeSession(email: string, cwd: string, sessionId: string): ClaudeSessionDetail | null { export function loadClaudeSession(who: ChatIdentity, cwd: string, sessionId: string): ClaudeSessionDetail | null {
const detail = parseClaudeTranscript( const detail = parseClaudeTranscript(
join(claudeProjectsDir(email), projectSlug(cwd), `${sessionId}.jsonl`), join(claudeProjectsDir(who.home), projectSlug(cwd), `${sessionId}.jsonl`),
sessionId, sessionId,
cwd, cwd,
); );
return detail && loadChainTranscript(email, detail); return detail && loadChainTranscript(who, detail);
} }
/** Resolve a session by id ALONE — scan every project group for its transcript. Used on a deep-link / /** Resolve a session by id ALONE — scan every project group for its transcript. Used on a deep-link /
* refresh to /chat/<id>, when the cwd isn't known yet; the transcript records the real cwd, which the * refresh to /chat/<id>, when the cwd isn't known yet; the transcript records the real cwd, which the
* caller uses to scope the list + cwd picker. */ * caller uses to scope the list + cwd picker. */
export function loadClaudeSessionById(email: string, sessionId: string): ClaudeSessionDetail | null { export function loadClaudeSessionById(who: ChatIdentity, sessionId: string): ClaudeSessionDetail | null {
const projectsDir = claudeProjectsDir(email); const projectsDir = claudeProjectsDir(who.home);
let slugs: string[]; let slugs: string[];
try { try {
slugs = readdirSync(projectsDir); slugs = readdirSync(projectsDir);
@@ -550,7 +559,7 @@ export function loadClaudeSessionById(email: string, sessionId: string): ClaudeS
const filePath = join(projectsDir, slug, `${sessionId}.jsonl`); const filePath = join(projectsDir, slug, `${sessionId}.jsonl`);
if (!existsSync(filePath)) continue; if (!existsSync(filePath)) continue;
const detail = parseClaudeTranscript(filePath, sessionId); const detail = parseClaudeTranscript(filePath, sessionId);
return detail && loadChainTranscript(email, detail); return detail && loadChainTranscript(who, detail);
} }
return null; return null;
} }
@@ -562,11 +571,11 @@ export function loadClaudeSessionById(email: string, sessionId: string): ClaudeS
* or a deep link hasn't resolved its group yet). Reads have always fallen back like this; writes did * or a deep link hasn't resolved its group yet). Reads have always fallen back like this; writes did
* not, so delete and rename returned "not found" for a session that was plainly on screen. * not, so delete and rename returned "not found" for a session that was plainly on screen.
*/ */
function findTranscript(email: string, cwd: string, sessionId: string): string | null { function findTranscript(who: ChatIdentity, cwd: string, sessionId: string): string | null {
const preferred = join(claudeProjectsDir(email), projectSlug(cwd), `${sessionId}.jsonl`); const preferred = join(claudeProjectsDir(who.home), projectSlug(cwd), `${sessionId}.jsonl`);
if (existsSync(preferred)) return preferred; if (existsSync(preferred)) return preferred;
const projectsDir = claudeProjectsDir(email); const projectsDir = claudeProjectsDir(who.home);
let slugs: string[]; let slugs: string[];
try { try {
slugs = readdirSync(projectsDir); slugs = readdirSync(projectsDir);
@@ -588,12 +597,12 @@ function findTranscript(email: string, cwd: string, sessionId: string): string |
* routinely (see `findTranscript`), and the wrong group would find no chain and silently delete one * routinely (see `findTranscript`), and the wrong group would find no chain and silently delete one
* part of several. * part of several.
*/ */
export function deleteClaudeSession(email: string, cwd: string, sessionId: string): boolean { export function deleteClaudeSession(who: ChatIdentity, cwd: string, sessionId: string): boolean {
const filePath = findTranscript(email, cwd, sessionId); const filePath = findTranscript(who, cwd, sessionId);
if (!filePath) return false; if (!filePath) return false;
const ownCwd = firstCwd(filePath); const ownCwd = firstCwd(filePath);
const ids = ownCwd ? chainFileIds(email, ownCwd, sessionId) : [sessionId]; const ids = ownCwd ? chainFileIds(who, ownCwd, sessionId) : [sessionId];
const dir = dirname(filePath); const dir = dirname(filePath);
for (const id of ids) { for (const id of ids) {
const partPath = join(dir, `${id}.jsonl`); const partPath = join(dir, `${id}.jsonl`);
@@ -607,8 +616,8 @@ export function deleteClaudeSession(email: string, cwd: string, sessionId: strin
* the title lives in .claude (source of truth). Our reader takes the last summary as the title; no * the title lives in .claude (source of truth). Our reader takes the last summary as the title; no
* timestamp is written so the rename doesn't reorder the list. * timestamp is written so the rename doesn't reorder the list.
*/ */
export function renameClaudeSession(email: string, cwd: string, sessionId: string, title: string): boolean { export function renameClaudeSession(who: ChatIdentity, cwd: string, sessionId: string, title: string): boolean {
const filePath = findTranscript(email, cwd, sessionId); const filePath = findTranscript(who, cwd, sessionId);
if (!filePath) return false; if (!filePath) return false;
// Attach the summary to the transcript's tip (the last entry carrying a uuid). // Attach the summary to the transcript's tip (the last entry carrying a uuid).
@@ -661,11 +670,11 @@ export type BackgroundTaskDetail =
| { kind: 'agent'; messages: ClaudeChatMessage[] } | { kind: 'agent'; messages: ClaudeChatMessage[] }
| { kind: 'log'; text: string; truncated: boolean }; | { kind: 'log'; text: string; truncated: boolean };
function findTaskOutput(email: string, taskId: string): string | null { function findTaskOutput(who: ChatIdentity, taskId: string): string | null {
const tmpRoot = process.env.TMPDIR ?? '/tmp'; const tmpRoot = process.env.TMPDIR ?? '/tmp';
const candidates: [string, string][] = [ const candidates: [string, string][] = [
[tmpRoot, `claude-*/*/*/tasks/${taskId}.output`], [tmpRoot, `claude-*/*/*/tasks/${taskId}.output`],
[claudeProjectsDir(email), `*/*/subagents/agent-${taskId}.jsonl`], [claudeProjectsDir(who.home), `*/*/subagents/agent-${taskId}.jsonl`],
]; ];
for (const [root, pattern] of candidates) { for (const [root, pattern] of candidates) {
try { try {
@@ -701,9 +710,9 @@ function tailFile(filePath: string, bytes: number): { text: string; truncated: b
* What a background task is doing right now. Returns null when nothing has been written yet which is * What a background task is doing right now. Returns null when nothing has been written yet which is
* the normal state for the first second or two of a task's life, not an error. * the normal state for the first second or two of a task's life, not an error.
*/ */
export function loadBackgroundTask(email: string, taskId: string): BackgroundTaskDetail | null { export function loadBackgroundTask(who: ChatIdentity, taskId: string): BackgroundTaskDetail | null {
if (!TASK_ID_RE.test(taskId)) return null; if (!TASK_ID_RE.test(taskId)) return null;
const found = findTaskOutput(email, taskId); const found = findTaskOutput(who, taskId);
if (!found) return null; if (!found) return null;
let target = found; let target = found;
@@ -753,10 +762,10 @@ function firstCwd(filePath: string): string {
export type ClaudePwd = { cwd: string; sessionCount: number; updatedAt: string; isDefault: boolean }; export type ClaudePwd = { cwd: string; sessionCount: number; updatedAt: string; isDefault: boolean };
/** All working directories that have Claude sessions, plus the default /chat dir. Newest first. */ /** All working directories that have Claude sessions, plus the caller's home. Newest first. */
export function listClaudePwds(email: string): ClaudePwd[] { export function listClaudePwds(who: ChatIdentity): ClaudePwd[] {
const projectsDir = claudeProjectsDir(email); const projectsDir = claudeProjectsDir(who.home);
const defaultCwd = getGeneralChatSessionsCwd(email); const defaultCwd = who.home;
const byCwd = new Map<string, { count: number; updatedAt: string }>(); const byCwd = new Map<string, { count: number; updatedAt: string }>();
if (existsSync(projectsDir)) { if (existsSync(projectsDir)) {
@@ -799,8 +808,8 @@ export function listClaudePwds(email: string): ClaudePwd[] {
* to its neighbours, so there is no per-file answer to cache. The per-file summaries underneath it are * to its neighbours, so there is no per-file answer to cache. The per-file summaries underneath it are
* mtime-cached, which is what makes calling this on every request cheap. * mtime-cached, which is what makes calling this on every request cheap.
*/ */
function scanGroup(email: string, cwd: string): TranscriptSummary[] { function scanGroup(who: ChatIdentity, cwd: string): TranscriptSummary[] {
const dir = join(claudeProjectsDir(email), projectSlug(cwd)); const dir = join(claudeProjectsDir(who.home), projectSlug(cwd));
if (!existsSync(dir)) return []; if (!existsSync(dir)) return [];
const sessions: TranscriptSummary[] = []; const sessions: TranscriptSummary[] = [];
@@ -813,8 +822,8 @@ function scanGroup(email: string, cwd: string): TranscriptSummary[] {
} }
/** Conversations Claude has stored for a working directory, newest first, one row per `/clear` chain. */ /** Conversations Claude has stored for a working directory, newest first, one row per `/clear` chain. */
export function listClaudeSessions(email: string, cwd: string): ClaudeSessionSummary[] { export function listClaudeSessions(who: ChatIdentity, cwd: string): ClaudeSessionSummary[] {
return mergeChains(scanGroup(email, cwd)) return mergeChains(scanGroup(who, cwd))
.map(publish) .map(publish)
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
} }
@@ -831,13 +840,13 @@ export function listClaudeSessions(email: string, cwd: string): ClaudeSessionSum
* still opens a real transcript, so answer for the part itself rather than 404 the title. * still opens a real transcript, so answer for the part itself rather than 404 the title.
*/ */
export function claudeSessionContext( export function claudeSessionContext(
email: string, who: ChatIdentity,
cwd: string, cwd: string,
sessionId: string, sessionId: string,
): { title: string; partCount: number } | null { ): { title: string; partCount: number } | null {
const merged = listClaudeSessions(email, cwd).find((entry) => entry.id === sessionId); const merged = listClaudeSessions(who, cwd).find((entry) => entry.id === sessionId);
if (merged) return { title: merged.title, partCount: merged.partCount ?? 1 }; if (merged) return { title: merged.title, partCount: merged.partCount ?? 1 };
const part = scanGroup(email, cwd).find((entry) => entry.id === sessionId); const part = scanGroup(who, cwd).find((entry) => entry.id === sessionId);
return part ? { title: part.title, partCount: 1 } : null; return part ? { title: part.title, partCount: 1 } : null;
} }
@@ -854,8 +863,8 @@ export function claudeSessionContext(
* of live sessions, so it is not worth a cache yet but it is worth knowing before this is called from * of live sessions, so it is not worth a cache yet but it is worth knowing before this is called from
* anywhere hotter. * anywhere hotter.
*/ */
export function liveSessionTitle(email: string, sessionId: string): { title: string; cwd: string } | null { export function liveSessionTitle(who: ChatIdentity, sessionId: string): { title: string; cwd: string } | null {
const projectsDir = claudeProjectsDir(email); const projectsDir = claudeProjectsDir(who.home);
let slugs: string[]; let slugs: string[];
try { try {
slugs = readdirSync(projectsDir); slugs = readdirSync(projectsDir);
@@ -884,7 +893,7 @@ export function liveSessionTitle(email: string, sessionId: string): { title: str
} }
if (!cwd) return null; if (!cwd) return null;
const context = claudeSessionContext(email, cwd, sessionId); const context = claudeSessionContext(who, cwd, sessionId);
return context ? { title: context.title, cwd } : null; return context ? { title: context.title, cwd } : null;
} }
@@ -897,8 +906,8 @@ export function liveSessionTitle(email: string, sessionId: string): { title: str
* the ancestors behind would resurrect them as separate rows the moment their child was gone, which * the ancestors behind would resurrect them as separate rows the moment their child was gone, which
* reads as the delete having half worked. * reads as the delete having half worked.
*/ */
function chainFileIds(email: string, cwd: string, sessionId: string): string[] { function chainFileIds(who: ChatIdentity, cwd: string, sessionId: string): string[] {
const group = scanGroup(email, cwd); const group = scanGroup(who, cwd);
const head = group.find((session) => session.id === sessionId); const head = group.find((session) => session.id === sessionId);
if (!head) return [sessionId]; if (!head) return [sessionId];
return chainOf(head, new Map(group.map((session) => [session.id, session]))).map((part) => part.id); return chainOf(head, new Map(group.map((session) => [session.id, session]))).map((part) => part.id);
+132 -20
View File
@@ -13,11 +13,12 @@ import { sessionManager } from './session-manager';
import { rememberOpenCodePrompt } from './opencode/state'; import { rememberOpenCodePrompt } from './opencode/state';
import { sendClaudeCodeStreaming } from '@@/channels/send-claude-code'; import { sendClaudeCodeStreaming } from '@@/channels/send-claude-code';
import { sendOpenCodeStreaming } from '@@/channels/send-opencode'; import { sendOpenCodeStreaming } from '@@/channels/send-opencode';
import { ensureGeneralChatSessionsCwd } from './claude-sessions';
import * as sidecar from '@@/sidecar-registry'; import * as sidecar from '@@/sidecar-registry';
import { join } from 'path'; import { join } from 'path';
import { getOwnerHomeDir, getEmailAccountsDir } from '../../../servers/data-path'; import { getOwnerHomeDir, getEmailAccountsDir } from '../../../servers/data-path';
import { getUserSettings, getEmailAccounts, getChatEventsSince, appendChatEvent } from 'officerdb'; import { getUserSettings, getEmailAccounts, getChatEventsSince, appendChatEvent, getUserById } from 'officerdb';
import { resolveHomeDir } from '@@/user-home';
import { claudeLoginState } from '@@/os-user-claude';
import { mkdirSync } from 'node:fs'; import { mkdirSync } from 'node:fs';
import { logger } from './logger'; import { logger } from './logger';
@@ -28,6 +29,40 @@ const DEFAULT_MODEL = 'claude-code';
// (opencode/anthropic/openai/… — all `providerID/modelID` ids) runs through the OpenCode server. // (opencode/anthropic/openai/… — all `providerID/modelID` ids) runs through the OpenCode server.
const isClaudeModel = (model: string): boolean => model.startsWith('claude-code'); const isClaudeModel = (model: string): boolean => model.startsWith('claude-code');
/**
* Whose identity a turn runs as.
*
* A three-way answer rather than a nullable one, because `undefined` downstream means **the owner** their
* binary, their `~/.claude` credential, their HOME, and their MCP config carrying `OFFICER_AUTH_TOKEN`. A
* nullable return collapsed three inputs into that: the caller genuinely being the owner, `resolveHomeDir`
* failing, and a member whose `osUser` is null. The last two mean "I could not determine whose this is", and
* answering them with the owner's identity is the one wrong answer this whole feature exists to prevent.
*
* Case three is not hypothetical: `provisionOsAccount` is non-fatal at every stage and records the account
* either way, so a member whose Linux provisioning failed exists as a row with no `osUser`. On the night this
* was written, provisioning failed three separate ways on a real member while the account continued to exist.
*
* The property: the owner's identity is reachable only by positively establishing that the caller IS the
* owner, never by failing to establish anything else. `resolveHomeDir` already reports `isOwner` as a positive
* fact the old funnel through `undefined` was the only thing throwing it away.
*/
type TurnIdentity =
| { kind: 'owner' }
| { kind: 'member'; run: { osUser: string; home: string } }
| { kind: 'refuse'; reason: string };
async function resolveTurnIdentity(userId: number): Promise<TurnIdentity> {
const resolved = await resolveHomeDir(userId);
if (!resolved.ok) return { kind: 'refuse', reason: resolved.reason };
if (resolved.isOwner) return { kind: 'owner' };
const row = await getUserById(userId);
if (!row?.osUser) {
return { kind: 'refuse', reason: 'your Linux account is not provisioned yet, so an agent cannot run as you' };
}
return { kind: 'member', run: { osUser: row.osUser, home: resolved.home } };
}
async function getUserDefaultModel(userId: number): Promise<string | null> { async function getUserDefaultModel(userId: number): Promise<string | null> {
try { try {
const settings = await getUserSettings(userId); const settings = await getUserSettings(userId);
@@ -48,16 +83,33 @@ type WSData = {
const IDLE_TIMEOUT_MS = 60 * 60 * 1000; // 1 hour const IDLE_TIMEOUT_MS = 60 * 60 * 1000; // 1 hour
const resolveCwd = (email: string, cwd?: string) => { /**
const root = getOwnerHomeDir(email); * Where a turn runs, relative to the caller's own home.
if (!cwd || cwd === '~') return root; *
if (cwd.startsWith('~/')) return join(root, cwd.slice(2)); * `root` used to be `getOwnerHomeDir(email)`, which ignores its argument whenever HOME_DIR is set so every
// The server owner is the only account — absolute paths are theirs to use. * `~` expanded to the OWNER'S home regardless of who asked, and the comment here said "the server owner is
* the only account" as though that were a property rather than an assumption.
*
* An absolute path is still passed through unchanged. That is not a hole: a member's turn runs as their Linux
* account, so the kernel decides what it can open, and containment is `resolveUserPath`'s job in the file
* browser rather than a string check here. But it is worth knowing it is the kernel doing the work.
*/
const resolveCwd = (home: string, cwd?: string) => {
if (!cwd || cwd === '~') return home;
if (cwd.startsWith('~/')) return join(home, cwd.slice(2));
if (cwd.startsWith('/')) return cwd; if (cwd.startsWith('/')) return cwd;
return join(root, cwd); return join(home, cwd);
}; };
export const resolveBaseCwd = (email: string, cwd?: string) => resolveCwd(email, cwd); /**
* @param home an absolute filesystem path NOT an email.
*
* It took an email until `95951fb`, resolved its own root, and both parameters are `string`, so the change of
* meaning was invisible to the compiler and to every caller outside that diff. Three of them kept passing an
* email and silently began building relative paths out of an address. If a fourth caller ever appears, this
* line is the warning it gets.
*/
export const resolveBaseCwd = (home: string, cwd?: string) => resolveCwd(home, cwd);
// The email chat runs from the selected account's storage dir: // The email chat runs from the selected account's storage dir:
// DATA_PATH/<owner>/email_accounts/<accountEmail> // DATA_PATH/<owner>/email_accounts/<accountEmail>
@@ -79,15 +131,31 @@ async function resolveEmailCwd(userId: number, ownerEmail: string, accountEmail?
} }
// The working directory a chat turn runs in, by context: email → the account dir; /chat → a chosen // The working directory a chat turn runs in, by context: email → the account dir; /chat → a chosen
// pwd or the default general_chat_sessions dir; everything else (browser/project/dashboard) → the given cwd. // pwd or the caller's own home; everything else (browser/project/dashboard) → the given cwd.
async function resolveChatCwd( async function resolveChatCwd(
msg: { context?: string; contextId?: string; cwd?: string }, msg: { context?: string; contextId?: string; cwd?: string },
email: string, email: string,
userId: number, userId: number,
home: string,
): Promise<string> { ): Promise<string> {
if (msg.context === 'email') return resolveEmailCwd(userId, email, msg.contextId); if (msg.context === 'email') return resolveEmailCwd(userId, email, msg.contextId);
if (msg.context === 'chat') return msg.cwd?.trim() ? resolveCwd(email, msg.cwd) : ensureGeneralChatSessionsCwd(email); if (msg.context === 'chat') {
return resolveCwd(email, msg.cwd); if (msg.cwd?.trim()) return resolveCwd(home, msg.cwd);
// The caller's own home, for everyone.
//
// This used to be `DATA_PATH/<email>/general_chat_sessions`, a dedicated directory so /chat sessions
// formed their own Claude project group and did not clutter the home. That is a sibling of the home,
// and `confineUserTree` makes every sibling the platform's at 0700 because the others are `attachments`
// and `email_accounts` — so it was unreachable for a member. The first live member turn ran there and
// every Bash call failed on its own working directory before doing anything.
//
// A per-member copy inside each home would have worked and would have left two rules to remember. The
// owner chose one: a chat with no chosen directory runs in the account's own home, whoever they are.
// The cost is that /chat sessions now share a project group with anything else run from that home,
// which was the reason the dedicated directory existed and is a trade the owner made knowingly.
return home;
}
return resolveCwd(home, msg.cwd);
} }
const wsToSessionMap = new WeakMap<any, string>(); const wsToSessionMap = new WeakMap<any, string>();
@@ -307,7 +375,41 @@ async function handleClaudeCodeChat(
): Promise<void> { ): Promise<void> {
const { email, username, userId } = ws.data; const { email, username, userId } = ws.data;
const cwd = await resolveChatCwd(msg, email, userId); // Identity first: it decides both whose home `~` expands against and whose account the turn runs as, and
// those must be the same answer. Resolving the cwd first would expand `~` before knowing whose it was.
const identity = await resolveTurnIdentity(userId);
if (identity.kind === 'refuse') {
sendToClient(ws, { type: 'error', message: identity.reason });
return;
}
// A member has to sign `claude` in themselves, once, with their own Anthropic account — the platform cannot
// do it for them without lending them the owner's credential, which is the thing this whole feature exists
// to avoid. Without this check their turn spawns, `claude` exits on an auth error, and it surfaces as "the
// agent is broken" — the exact confusion `/agent-status` was built to prevent, arriving through a different
// door. The refusal carries the instruction so the answer is the same whether the UI asked or not.
if (identity.kind === 'member') {
const state = await claudeLoginState({ email, osUser: identity.run.osUser });
if (!state.installed) {
sendToClient(ws, {
type: 'error',
message: 'Claude is not installed in your home yet — ask the server owner to reprovision your account.',
});
return;
}
if (!state.loggedIn) {
sendToClient(ws, {
type: 'error',
message:
'Open a terminal and run `claude` once to sign in with your own Anthropic account. It stays signed in.',
});
return;
}
}
const home = identity.kind === 'member' ? identity.run.home : getOwnerHomeDir(email);
const cwd = await resolveChatCwd(msg, email, userId, home);
const groupSlug = msg.groupSlug || null; const groupSlug = msg.groupSlug || null;
@@ -359,6 +461,7 @@ async function handleClaudeCodeChat(
sessionKey: sessionId, sessionKey: sessionId,
cwd, cwd,
model, model,
member: identity.kind === 'member' ? identity.run : undefined,
resumeSessionId: msg.resumeSessionId, resumeSessionId: msg.resumeSessionId,
onMessage, onMessage,
}); });
@@ -408,7 +511,9 @@ async function handleOpenCodeChat(
): Promise<void> { ): Promise<void> {
const { email, username, userId } = ws.data; const { email, username, userId } = ws.data;
const cwd = await resolveChatCwd(msg, email, userId); // The owner's home: opencode receives no identity at all (`TODO.md` → Multi-user), so this path is
// owner-only and resolving anything else here would imply an isolation it does not have.
const cwd = await resolveChatCwd(msg, email, userId, getOwnerHomeDir(email));
// Names this session in the Live panel until OpenCode gets round to titling it. First turn only. // Names this session in the Live panel until OpenCode gets round to titling it. First turn only.
rememberOpenCodePrompt(sessionId, msg.displayText || msg.prompt); rememberOpenCodePrompt(sessionId, msg.displayText || msg.prompt);
@@ -537,7 +642,7 @@ async function handleStop(ws: ServerWebSocket<WSData>): Promise<void> {
if (isClaudeModel(session.model)) { if (isClaudeModel(session.model)) {
// Interrupt the current turn but KEEP the persistent session alive (background tasks + the // Interrupt the current turn but KEEP the persistent session alive (background tasks + the
// warm worker survive). Full teardown is 'disconnect' → deleteSession → _claudeKill. // warm worker survive). Full teardown is 'disconnect' → deleteSession → _claudeKill.
void sidecar.interruptClaude(sessionId); void sidecar.interruptClaude(sessionId, ws.data.userId);
logger.info('Interrupted Claude Code turn via sidecar (session stays warm)', { sessionId }); logger.info('Interrupted Claude Code turn via sidecar (session stays warm)', { sessionId });
} else { } else {
session._claudeKill?.(); // OpenCode: abort the turn via the stored handle session._claudeKill?.(); // OpenCode: abort the turn via the stored handle
@@ -604,7 +709,7 @@ function adoptOrphanedSession(ws: ServerWebSocket<WSData>, sessionId: string, mo
// Must be set, and not only for teardown: handleChat treats an absent `_claudeKill` as "first turn of // Must be set, and not only for teardown: handleChat treats an absent `_claudeKill` as "first turn of
// this session" and opens a *second* subscription, which would then deliver every message twice. // this session" and opens a *second* subscription, which would then deliver every message twice.
session._claudeKill = () => { session._claudeKill = () => {
if (isClaudeModel(model)) sidecar.killClaude(sessionId); if (isClaudeModel(model)) sidecar.killClaude(sessionId, userId);
else sidecar.killOpenCode(sessionId); else sidecar.killOpenCode(sessionId);
unsub(); unsub();
}; };
@@ -653,7 +758,7 @@ async function handleResumeCursor(
// the Claude sidecar about an OpenCode session, hear "not generating", and write "the agent went // the Claude sidecar about an OpenCode session, hear "not generating", and write "the agent went
// away" into a turn that was running perfectly well. // away" into a turn that was running perfectly well.
if (msg.generating && decision.kind !== 'assume') { if (msg.generating && decision.kind !== 'assume') {
await endTurnIfAgentIsGone([ws], sessionId, decision.model); await endTurnIfAgentIsGone([ws], sessionId, decision.model, ws.data.userId);
} }
} }
@@ -738,7 +843,7 @@ async function handleAttach(ws: ServerWebSocket<WSData>, msg: { claudeSessionId:
const { claudeSessionId } = msg; const { claudeSessionId } = msg;
if (!claudeSessionId) return; if (!claudeSessionId) return;
const sessionId = await sidecar.findClaudeSessionKey(claudeSessionId); const sessionId = await sidecar.findClaudeSessionKey(claudeSessionId, ws.data.userId);
if (!sessionId) { if (!sessionId) {
// No agent, or a transcript it has never run. Nothing is wrong: an ordinary finished conversation // No agent, or a transcript it has never run. Nothing is wrong: an ordinary finished conversation
// opened from history lands here every time. Stay silent and leave the socket as it was — the next // opened from history lands here every time. Stay silent and leave the socket as it was — the next
@@ -774,7 +879,7 @@ async function handleAttach(ws: ServerWebSocket<WSData>, msg: { claudeSessionId:
// `isGenerating` is officer's own belief and is only as good as this process's memory of the turn. For // `isGenerating` is officer's own belief and is only as good as this process's memory of the turn. For
// an adopted session it is a fresh record's default, so ask the agent — the same question, and for the // an adopted session it is a fresh record's default, so ask the agent — the same question, and for the
// same reason, as `endTurnIfAgentIsGone`. // same reason, as `endTurnIfAgentIsGone`.
const isGenerating = existing ? session.isGenerating : await sidecar.isClaudeGenerating(sessionId); const isGenerating = existing ? session.isGenerating : await sidecar.isClaudeGenerating(sessionId, ws.data.userId);
session.isGenerating = isGenerating; session.isGenerating = isGenerating;
// One read serves both answers: the head of the log is the cursor, and folding the whole log gives the // One read serves both answers: the head of the log is the cursor, and folding the whole log gives the
@@ -826,9 +931,10 @@ async function endTurnIfAgentIsGone(
targets: Iterable<ServerWebSocket<WSData> | null>, targets: Iterable<ServerWebSocket<WSData> | null>,
sessionId: string, sessionId: string,
model: string, model: string,
userId: number,
): Promise<void> { ): Promise<void> {
if (!isClaudeModel(model)) return; if (!isClaudeModel(model)) return;
if (await sidecar.isClaudeGenerating(sessionId)) return; if (await sidecar.isClaudeGenerating(sessionId, userId)) return;
const session = sessionManager.getSession(sessionId); const session = sessionManager.getSession(sessionId);
if (session) session.isGenerating = false; if (session) session.isGenerating = false;
@@ -852,10 +958,16 @@ async function endTurnIfAgentIsGone(
sidecar.onClaudeSidecarStarted(() => { sidecar.onClaudeSidecarStarted(() => {
for (const session of sessionManager.getAllSessions()) { for (const session of sessionManager.getAllSessions()) {
if (!session.isGenerating) continue; if (!session.isGenerating) continue;
// No owner recorded, no question asked. `isClaudeGenerating` is now scoped to a caller, and there is no
// safe id to substitute — asking as the owner would let a member's orphaned session be answered with the
// owner's authority, and asking as nobody is not a thing. Leaving it marked generating is the same
// outcome as before this loop existed, and it self-corrects on the next reconnect.
if (session.userId === undefined) continue;
void endTurnIfAgentIsGone( void endTurnIfAgentIsGone(
session.sockets as Set<ServerWebSocket<WSData>>, session.sockets as Set<ServerWebSocket<WSData>>,
session.sessionId, session.sessionId,
session.model, session.model,
session.userId,
); );
} }
}); });
+59 -12
View File
@@ -3,6 +3,7 @@ import { resolve, dirname, join, sep, parse as parsePath } from 'node:path';
import { readdir, stat, mkdir, rm, rename, readFile, cp } from 'node:fs/promises'; import { readdir, stat, mkdir, rm, rename, readFile, cp } from 'node:fs/promises';
import { existsSync } from 'node:fs'; import { existsSync } from 'node:fs';
import { getOwnerHomeDir, DATA_PATH } from '@@/data-path'; import { getOwnerHomeDir, DATA_PATH } from '@@/data-path';
import { resolveHomeDir } from '@@/user-home';
import * as errors from '@@/custom-errors'; import * as errors from '@@/custom-errors';
import { readTtsConfig } from '@@/api/server-settings/tts'; import { readTtsConfig } from '@@/api/server-settings/tts';
import { readSttConfig } from '@@/api/server-settings/stt'; import { readSttConfig } from '@@/api/server-settings/stt';
@@ -18,7 +19,6 @@ async function getUserTtsVoice(userId: number): Promise<string | null> {
return null; return null;
} }
const DEFAULT_HOME_DIRS = ['Downloads', 'Documents', 'Music', 'Videos', 'Pictures'];
const OLD_CACHE_DIRS = ['ocr', 'tts', 'transcriptions', 'audio', 'video']; const OLD_CACHE_DIRS = ['ocr', 'tts', 'transcriptions', 'audio', 'video'];
async function cleanOldCacheDirs(userDataDir: string) { async function cleanOldCacheDirs(userDataDir: string) {
@@ -28,23 +28,51 @@ async function cleanOldCacheDirs(userDataDir: string) {
} }
} }
async function seedHomeDir(homeDir: string) { // `seedHomeDir` used to be here, creating Downloads/Documents/Music/Videos/Pictures on the first listing of
for (const dir of DEFAULT_HOME_DIRS) { // any home. Removed 2026-08-11: it invented folders in somebody's home directory as a side effect of LOOKING
const target = join(homeDir, dir); // at it, which is not a listing's business and not a layout the platform has any standing to choose.
if (!existsSync(target)) await mkdir(target, { recursive: true });
}
}
export const router = createRouter(); export const router = createRouter();
type UserCtx = { email: string }; /**
* Resolve whose home this request may touch, once, before any handler runs.
*
* A middleware rather than a change to `getRootDir`'s signature because that function is called from
* fifteen places in this file. Making it async would have meant editing fifteen call sites, and the
* failure mode of missing one is the worst available: a handler that quietly serves the OWNER'S home to a
* member. Resolving here means a handler cannot run without the answer.
*
* The `user-data` root is untouched by this it is already keyed on the caller's own email and holds
* platform-written data rather than anything executable.
*/
router.use(async (ctx, next) => {
const user = ctx.get('user');
const resolved = await resolveHomeDir(user.id as number);
if (!resolved.ok) {
throw resolved.needsOsAccount
? errors.FORBIDDEN(`Files are not available for this account: ${resolved.reason}.`)
: errors.FORBIDDEN(resolved.reason);
}
ctx.set('user', { ...user, homeDir: resolved.home });
return next();
});
/**
* `homeDir` is put on the context user by `confineToHome` below, so the fifteen-odd call sites of
* `getRootDir` keep working unchanged and none of them can forget to resolve it.
*/
type UserCtx = { email: string; homeDir?: string };
function getUserDataDir(email: string): string { function getUserDataDir(email: string): string {
return join(DATA_PATH, email); return join(DATA_PATH, email);
} }
export function getRootDir(user: UserCtx, root?: string): string { export function getRootDir(user: UserCtx, root?: string): string {
if (!root || root === 'home') return getOwnerHomeDir(user.email); // `user.homeDir` is set for every request that reached a handler — the middleware refuses the request
// otherwise. The fallback exists only for the owner-shaped callers that construct a UserCtx by hand;
// it is NOT a "member without an OS account gets the owner's home" path, because such a request never
// gets this far. See user-home.ts for why that distinction is the whole point.
if (!root || root === 'home') return user.homeDir ?? getOwnerHomeDir(user.email);
if (root === 'user-data') return getUserDataDir(user.email); if (root === 'user-data') return getUserDataDir(user.email);
throw errors.BAD_REQUEST(`Invalid root: ${root}`); throw errors.BAD_REQUEST(`Invalid root: ${root}`);
} }
@@ -140,10 +168,17 @@ router.get('/ls', async (ctx) => {
const relPath = (ctx.req.query('path') || '/').replace(/^\/+/, ''); const relPath = (ctx.req.query('path') || '/').replace(/^\/+/, '');
const absPath = resolveUserPath(rootDir, relPath); const absPath = resolveUserPath(rootDir, relPath);
// Auto-create dir if missing (only for user home root) // Create the home root itself if it is missing, and nothing else. A listing that invents its own contents
// is a listing you cannot trust — the folder set it used to seed is gone.
//
// Non-fatal: a member's home is theirs, so this can raise EPERM, and `readdir` below is the real test of
// whether the directory can be used.
if (!ctx.req.query('root') || ctx.req.query('root') === 'home') { if (!ctx.req.query('root') || ctx.req.query('root') === 'home') {
await seedHomeDir(rootDir); try {
await mkdir(absPath, { recursive: true }); await mkdir(absPath, { recursive: true });
} catch {
// Either it exists, or it is not ours to create.
}
} }
// Remove old top-level cache dirs (migrated to cache/ prefix) // Remove old top-level cache dirs (migrated to cache/ prefix)
@@ -154,7 +189,19 @@ router.get('/ls', async (ctx) => {
let names: string[]; let names: string[];
try { try {
names = await readdir(absPath); names = await readdir(absPath);
} catch { } catch (ex) {
// A missing directory resets the browser to the root, which is the right answer for a stale path.
//
// A PERMISSION failure is not that, and conflating them cost an afternoon: a member's home is 700 and
// theirs, so before the ACL grant in os-user.ts the platform's readdir raised EACCES here and this
// returned an empty listing — the UI said "This folder is empty" over five directories that existed.
// An empty result is data; it should never be how a refusal looks.
if ((ex as { code?: string }).code === 'EACCES' || (ex as { code?: string }).code === 'EPERM') {
throw errors.FORBIDDEN(
`Officer cannot read ${relPath || 'this folder'}. If this is a member's home, its access control ` +
`lists are missing — reprovision the Linux account from Settings → User management.`,
);
}
return ctx.json({ path: '/', entries: [], reset: true }); return ctx.json({ path: '/', entries: [], reset: true });
} }
const entries = await Promise.all( const entries = await Promise.all(
-30
View File
@@ -1,30 +0,0 @@
import { createRouter } from '../../create-router';
import { readdir } from 'node:fs/promises';
import { basename, join } from 'node:path';
const plansDir = join(process.cwd(), 'plans');
export const plansRouter = createRouter();
plansRouter.get('/', async (ctx) => {
try {
const files = await readdir(plansDir);
const plans = files.filter((f) => f.endsWith('.md')).map((f) => f.replace('.md', ''));
return ctx.json(plans);
} catch {
return ctx.json([]);
}
});
plansRouter.get('/:name', async (ctx) => {
// A single path segment is not a single *name*: hono percent-decodes params, so `..%2F..%2Fsecret`
// arrives here as `../../secret` and `join` would happily walk out of plansDir. Verified against hono
// directly. Auth limits the blast radius to the owner's own token, and the `.md` suffix limits it to
// markdown, but "read any .md on the disk" is not what this endpoint is for.
const name = basename(ctx.req.param('name'));
const filePath = join(plansDir, `${name}.md`);
const file = Bun.file(filePath);
if (!(await file.exists())) return ctx.text('Not found', 404);
const text = await file.text();
return ctx.text(text);
});
+3 -3
View File
@@ -5,7 +5,7 @@ import { join } from 'node:path';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { getUserSettings } from 'officerdb'; import { getUserSettings } from 'officerdb';
import { getTaskByDirName } from './task-files'; import { getTaskByDirName } from './task-files';
import { getHomeDir } from '../../data-path'; import { getHomeDir, getOwnerHomeDir } from '../../data-path';
import { resolveBaseCwd } from '../chat/websocket'; import { resolveBaseCwd } from '../chat/websocket';
import { sendClaudeCodeStreaming } from '../../channels/send-claude-code'; import { sendClaudeCodeStreaming } from '../../channels/send-claude-code';
import type { TurnMessage, MessageCost } from '../chat/types'; import type { TurnMessage, MessageCost } from '../chat/types';
@@ -496,7 +496,7 @@ async function runForeach({
} }
const cwdRelative = cwd ? `${cwd}/${subdir}` : subdir; const cwdRelative = cwd ? `${cwd}/${subdir}` : subdir;
const resolvedCwd = resolveBaseCwd(email, cwdRelative); const resolvedCwd = resolveBaseCwd(getOwnerHomeDir(email), cwdRelative);
const targetDir = resolvedCwd; const targetDir = resolvedCwd;
const prompt = buildStepPrompt(stepTask.body!, iterInputs, targetDir); const prompt = buildStepPrompt(stepTask.body!, iterInputs, targetDir);
@@ -588,7 +588,7 @@ export async function executePipeline({
return; return;
} }
const baseCwd = resolveBaseCwd(email, cwd); const baseCwd = resolveBaseCwd(getOwnerHomeDir(email), cwd);
let model = modelOverride || (await resolveModel(userId)); let model = modelOverride || (await resolveModel(userId));
// Claude-only: coerce any legacy non-Claude task-model preference to the Claude default. // Claude-only: coerce any legacy non-Claude task-model preference to the Claude default.
if (!model.startsWith('claude-code')) model = DEFAULT_MODEL; if (!model.startsWith('claude-code')) model = DEFAULT_MODEL;
+46 -8
View File
@@ -6,6 +6,7 @@ import { getAllRoleGrants, replaceRoleGrants, USER_ROLES } from 'officerdb';
import type { UserRole } from 'officerdb'; import type { UserRole } from 'officerdb';
import { CAPABILITIES, GRANTABLE_CAPABILITIES, CAPABILITY_BY_KEY } from '../../capabilities/registry'; import { CAPABILITIES, GRANTABLE_CAPABILITIES, CAPABILITY_BY_KEY } from '../../capabilities/registry';
import { getEffectiveCapabilities, invalidateRoleGrants } from '../../capabilities/authorize'; import { getEffectiveCapabilities, invalidateRoleGrants } from '../../capabilities/authorize';
import { capabilityAvailability } from '../../app-store/availability';
// Two audiences, deliberately split. // Two audiences, deliberately split.
// //
@@ -28,6 +29,11 @@ selfCapabilitiesRouter.get('/capabilities', async (ctx) => {
const userId = ctx.get('user').id as number; const userId = ctx.get('user').id as number;
const { isOwner, grants } = await getEffectiveCapabilities(userId); const { isOwner, grants } = await getEffectiveCapabilities(userId);
// What EXISTS on this server, which is a different question from what this account may use. A
// capability the owner holds unconditionally still means nothing if its sidecar was never installed,
// and the owner is as subject to that as a member — see app-store/availability.ts.
const { unavailable, manifests } = await capabilityAvailability();
// The owner holds everything, and says so by listing it rather than by a flag the frontend has to // The owner holds everything, and says so by listing it rather than by a flag the frontend has to
// remember to special-case. One shape for both audiences means one code path in the UI. // remember to special-case. One shape for both audiences means one code path in the UI.
const held = isOwner const held = isOwner
@@ -35,16 +41,34 @@ selfCapabilitiesRouter.get('/capabilities', async (ctx) => {
: [...grants].map(([key, level]) => ({ key, level })); : [...grants].map(([key, level]) => ({ key, level }));
const heldKeys = new Set(held.map((h) => h.key)); const heldKeys = new Set(held.map((h) => h.key));
// Held AND present. Two subtractions rather than one because they mean different things to the UI: a
// capability withheld is "not yours", one whose sidecar is absent is "not here yet, install it".
const usable = held.filter(({ key }) => !unavailable.has(key));
return ctx.json({ return ctx.json({
isOwner, isOwner,
capabilities: held, capabilities: held,
/** Capabilities the account holds whose sidecar is not installed or is disabled. */
unavailable: [...unavailable].filter((key) => heldKeys.has(key)),
/**
* Dock tiles and routes belonging to installed sidecars the account may reach.
*
* Filtered by capability here rather than in the client: a member must not be handed the manifest
* of a feature they cannot use, even to hide it, because "hidden in the client" is the kind of
* privacy that lasts until someone opens the network tab.
*/
plugins: manifests.filter((m) => !m.capability || heldKeys.has(m.capability)),
// Flattened for the dock and the route guard, which care about paths rather than capability keys. // Flattened for the dock and the route guard, which care about paths rather than capability keys.
routes: held.flatMap(({ key }) => CAPABILITY_BY_KEY.get(key)?.routes ?? []), routes: usable.flatMap(({ key }) => CAPABILITY_BY_KEY.get(key)?.routes ?? []),
// The complement, and the frontend genuinely needs both. "Not in `routes`" cannot distinguish a route // The complement, and the frontend genuinely needs both. "Not in `routes`" cannot distinguish a route
// this account lacks from a route no capability claims at all — `/`, the settings shell, the sign-in // this account lacks from a route no capability claims at all — `/`, the settings shell, the sign-in
// screens — and a guard that cannot tell those apart either blanks the app or guards nothing. // screens — and a guard that cannot tell those apart either blanks the app or guards nothing.
deniedRoutes: CAPABILITIES.filter((c) => !heldKeys.has(c.key)).flatMap((c) => c.routes ?? []), // Routes of capabilities this account does not hold, PLUS those whose sidecar is not installed. The
// guard treats both the same — there is nothing to show — while `unavailable` above lets the UI
// explain the second case as something the owner can fix by installing it.
deniedRoutes: CAPABILITIES.filter((c) => !heldKeys.has(c.key) || unavailable.has(c.key)).flatMap(
(c) => c.routes ?? [],
),
}); });
}); });
@@ -52,17 +76,31 @@ selfCapabilitiesRouter.get('/capabilities', async (ctx) => {
export const capabilityAdminRouter = createRouter(); export const capabilityAdminRouter = createRouter();
capabilityAdminRouter.get('/capabilities', ownerGate, async (ctx) => { capabilityAdminRouter.get('/capabilities', ownerGate, async (ctx) => {
return ctx.json({ // Only what this server can actually do RIGHT NOW.
// Only the grantable kind is offered. `execution` and `admin` are deliberately not in this list: //
// a UI that shows a checkbox it will refuse to honour is worse than one that never offered it. // The same subtraction the dock already makes, applied to the granting UI — which was showing all
capabilities: GRANTABLE_CAPABILITIES.map((c) => ({ // fourteen app capabilities on a fresh install where none of their sidecars existed. Offering to grant
// Photos on a machine with no Immich is not a permission decision, it is a menu of things that would
// 403 for a different reason than the owner thinks.
//
// Fail open on a degraded read: `capabilityAvailability` returns an empty `unavailable` set when it
// cannot see install state, so the list falls back to everything rather than to nothing. An owner whose
// Permissions screen emptied itself because one query failed would reasonably conclude the feature broke.
const { unavailable } = await capabilityAvailability();
const describe = (c: (typeof GRANTABLE_CAPABILITIES)[number]) => ({
key: c.key, key: c.key,
label: c.label, label: c.label,
description: c.description, description: c.description,
// What the owner is actually deciding about, shown so the grant is legible rather than a name. // What the owner is actually deciding about, shown so the grant is legible rather than a name.
routes: c.routes ?? [], routes: c.routes ?? [],
hasPersonalWrites: !!c.personal?.length, hasPersonalWrites: !!c.personal?.length,
})), });
return ctx.json({
// Only the grantable kinds are offered. `execution` and `admin` are deliberately absent: a UI that
// shows a checkbox it will refuse to honour is worse than one that never offered it.
capabilities: GRANTABLE_CAPABILITIES.filter((c) => !unavailable.has(c.key)).map(describe),
// Roles a grant may name. Super Admin is excluded: the owner bypasses this table entirely, and the // Roles a grant may name. Super Admin is excluded: the owner bypasses this table entirely, and the
// database refuses a row for that role. // database refuses a row for that role.
roles: USER_ROLES.filter((r) => r !== 'Super Admin'), roles: USER_ROLES.filter((r) => r !== 'Super Admin'),
@@ -90,7 +128,7 @@ capabilityAdminRouter.put('/capabilities/:role', ownerGate, async (ctx) => {
// grant the resolver would drop on read anyway. // grant the resolver would drop on read anyway.
const known = CAPABILITY_BY_KEY.get(capability); const known = CAPABILITY_BY_KEY.get(capability);
if (!known) throw errors.BAD_REQUEST(`Unknown capability '${capability}'`); if (!known) throw errors.BAD_REQUEST(`Unknown capability '${capability}'`);
if (known.kind !== 'app') { if (known.kind !== 'app' && known.kind !== 'confined') {
throw errors.BAD_REQUEST( throw errors.BAD_REQUEST(
known.kind === 'execution' known.kind === 'execution'
? `${known.label} runs as the server owner and can never be granted` ? `${known.label} runs as the server owner and can never be granted`
+113
View File
@@ -0,0 +1,113 @@
import type { Handler } from 'hono';
import { createUser, getUserByEmail, getUserByUsername, USER_ROLES } from 'officerdb';
import type { UserRole } from 'officerdb';
import argon2 from 'argon2';
import * as errors from '@@/custom-errors';
import { OS_USERS_ENABLED } from '@@/os-user';
import { validatePublicKey } from '@@/os-user-ssh';
import { provisionOsAccount } from './provision-os';
import { validatePassword } from '../auth/validate-password';
import { validateUsername } from '../auth/validate-username';
import { toPublicUser } from './manage-users';
// The owner creating a second account. Until this existed, `createUser` had exactly one call site —
// `auth/bootstrap.ts`, gated on an empty user table — so every non-owner account on any instance had
// been inserted into Postgres by hand.
//
// ── Why the owner sets the password ──
//
// The alternative is an invite: a token emailed to the person, who then sets their own. That is the
// better shape and it needs a mail path, a token table and an expiry policy. This is the honest
// intermediate: the owner types a password and tells the person, the same way they would hand over a
// wifi key. `passwordChangedAt` stays null, so nothing pretends the person chose it.
//
// ── Status is 'Active', deliberately ──
//
// The column defaults to 'Unverified' and `signin.ts` refuses anything that is not 'Active' with a bare
// UNAUTHORIZED. So an account created at the default would be indistinguishable from a wrong password,
// which is precisely the trap the hand-INSERT route fell into. An account the owner created in the admin
// UI is verified by definition — the owner is the verification.
/** Roles this route may assign. Never 'Super Admin' — see below. */
const ASSIGNABLE_ROLES = USER_ROLES.filter((r) => r !== 'Super Admin');
export const createUserHandler: Handler = async function (ctx) {
const body = (ctx.get('body') ?? {}) as Record<string, unknown>;
const email = typeof body.email === 'string' ? body.email.trim().toLowerCase() : '';
const name = typeof body.name === 'string' ? body.name.trim() : '';
const password = typeof body.password === 'string' ? body.password : '';
const role = typeof body.role === 'string' ? body.role : 'Member';
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) throw errors.BAD_REQUEST('Invalid email address');
if (!name) throw errors.BAD_REQUEST('Name is required');
// The same validators bootstrap uses. A member's password protects the same surface a member can
// reach, so there is no argument for a weaker rule here — and two different rules would mean the
// owner could create an account that could not then change its own password to something similar.
const username = validateUsername(typeof body.username === 'string' ? body.username : undefined);
validatePassword(password);
// Refused rather than filtered, so the owner is told instead of quietly getting a Member.
//
// There is exactly one owner. The database only pins user 1's role — a row-level CHECK cannot say
// "no OTHER row may hold this" — so a second Super Admin is storable, and `getOwnerUser()` would then
// return whichever the query reached first. That answer decides the identity the agent sidecar runs
// as, who reaches the vault and which origin is privileged, so it is not a thing to leave to a query
// plan. If the owner ever needs to hand the server over, that is a deliberate transfer, not a dropdown.
if (!(ASSIGNABLE_ROLES as readonly string[]).includes(role)) {
throw errors.BAD_REQUEST(
role === 'Super Admin'
? 'There is one server owner and it cannot be created here.'
: `Role must be one of: ${ASSIGNABLE_ROLES.join(', ')}`,
);
}
// The inbound SSH key, if the owner supplied one. Validated HERE rather than at use, so a bad paste is a
// 400 on the form instead of an account that exists with a confusing warning attached.
//
// Optional by design: an account with no inbound key is platform-only, which is a perfectly good state.
// The OUTBOUND key is generated regardless — see os-user-ssh.ts for why those are not alternatives.
const rawKey = typeof body.sshPublicKey === 'string' ? body.sshPublicKey.trim() : '';
let inboundKey: string | null = null;
if (rawKey) {
const checked = validatePublicKey(rawKey);
if (!checked.ok) throw errors.BAD_REQUEST(`SSH public key: ${checked.error}`);
inboundKey = checked.key;
}
// Checked before the insert purely for the message — both columns are unique, so the database is the
// real guard and this is a race it can lose harmlessly (the insert then throws).
if (await getUserByEmail(email)) throw errors.CONFLICT('An account with that email already exists');
if (await getUserByUsername(username)) throw errors.CONFLICT('That username is taken');
const user = await createUser({
email,
password: await argon2.hash(password),
name,
username,
status: 'Active',
role: role as UserRole,
});
// The Linux side: directories, the Linux user, the confinement, the keys. Non-fatal and reported rather
// than thrown — this is a side effect of creating a platform account, and a failed `useradd` must not undo
// an account that otherwise exists and can sign in. The row simply keeps `osUser: null`, which is what an
// account created before this feature looks like, and which every consumer already handles.
//
// Retryable in place afterwards via POST /users/:id/provision-linux, so a host that was not ready when the
// account was made does not cost anybody their password and dashboards.
const os = OS_USERS_ENABLED
? await provisionOsAccount({ userId: user.id, email, username, inboundKey })
: { osUser: null, sshPublicKey: null, error: null };
if (os.error) console.warn(`[users] created ${email} but its Linux side did not finish: ${os.error}`);
return ctx.json(
{
user: { ...toPublicUser(user), osUser: os.osUser, osSshPublicKey: os.sshPublicKey },
osUserError: os.error,
},
201,
);
};
+28 -1
View File
@@ -2,6 +2,7 @@ import type { Handler } from 'hono';
import { getUsers, getUserById, updateUser, deleteUser, USER_ROLES, OWNER_USER_ID } from 'officerdb'; import { getUsers, getUserById, updateUser, deleteUser, USER_ROLES, OWNER_USER_ID } from 'officerdb';
import type { UserRole } from 'officerdb'; import type { UserRole } from 'officerdb';
import * as errors from '@@/custom-errors'; import * as errors from '@@/custom-errors';
import { OS_USERS_ENABLED } from '@@/os-user';
// Owner-only management of the other accounts. Everything here is gated by ownerGate in // Owner-only management of the other accounts. Everything here is gated by ownerGate in
// users-router.ts; these handlers assume the caller is the Super Admin. // users-router.ts; these handlers assume the caller is the Super Admin.
@@ -19,9 +20,18 @@ type PublicUser = {
createdAt: Date; createdAt: Date;
/** True for the bootstrap account. The UI uses it to lock the row; the database enforces it. */ /** True for the bootstrap account. The UI uses it to lock the row; the database enforces it. */
isOwner: boolean; isOwner: boolean;
/** The Linux account this runs as, or null where per-user OS accounts are off. */
osUser: string | null;
/**
* The public half of their generated SSH key. Listed because it has an errand attached it must be
* added to their Gitea account and the create form promises it is retrievable here afterwards. Public
* by definition, so no reason to withhold it from the owner-only endpoint that already returns emails.
*/
osSshPublicKey: string | null;
}; };
const toPublicUser = (u: Awaited<ReturnType<typeof getUsers>>[number]): PublicUser => ({ /** Shared with create-user.ts, so a created account and a listed one are described the same way. */
export const toPublicUser = (u: Awaited<ReturnType<typeof getUsers>>[number]): PublicUser => ({
id: u.id, id: u.id,
email: u.email, email: u.email,
name: u.name, name: u.name,
@@ -31,14 +41,22 @@ const toPublicUser = (u: Awaited<ReturnType<typeof getUsers>>[number]): PublicUs
role: u.role, role: u.role,
createdAt: u.createdAt, createdAt: u.createdAt,
isOwner: u.id === OWNER_USER_ID, isOwner: u.id === OWNER_USER_ID,
osUser: u.osUser,
osSshPublicKey: u.osSshPublicKey,
}); });
export const listUsersHandler: Handler = async function (ctx) { export const listUsersHandler: Handler = async function (ctx) {
const users = await getUsers(); const users = await getUsers();
return ctx.json({ return ctx.json({
users: users.sort((a, b) => a.id - b.id).map(toPublicUser), users: users.sort((a, b) => a.id - b.id).map(toPublicUser),
// Every role, so the owner's own row can display its value. The UI must not offer 'Super Admin' in a
// picker — both write paths refuse it — which is what `assignableRoles` is for.
roles: USER_ROLES, roles: USER_ROLES,
assignableRoles: USER_ROLES.filter((r) => r !== 'Super Admin'),
ownerId: OWNER_USER_ID, ownerId: OWNER_USER_ID,
// So the UI offers the Linux-account controls only where they can work. On a host without the feature
// they would be a button that always reports the same refusal.
osUsersEnabled: OS_USERS_ENABLED,
}); });
}; };
@@ -58,6 +76,15 @@ export const updateUserRoleHandler: Handler = async function (ctx) {
throw errors.FORBIDDEN('The server owner cannot be demoted.'); throw errors.FORBIDDEN('The server owner cannot be demoted.');
} }
// And nobody else can be promoted INTO it. The CHECK constraint pins user 1's role but cannot stop a
// second row holding it — a row-level check cannot see other rows — and `getOwnerUser()` resolves the
// owner by that role, so two holders make "who owns this server" a question the query plan answers.
// It decides the agent sidecar's identity, vault access and which origin is privileged. Handing the
// server over is a deliberate act, not a dropdown.
if (id !== OWNER_USER_ID && role === 'Super Admin') {
throw errors.FORBIDDEN('There is one server owner, and this is not how it changes.');
}
const existing = await getUserById(id); const existing = await getUserById(id);
if (!existing) throw errors.NOT_FOUND('User not found'); if (!existing) throw errors.NOT_FOUND('User not found');
@@ -0,0 +1,54 @@
import type { Handler } from 'hono';
import { getUserById, OWNER_USER_ID } from 'officerdb';
import * as errors from '@@/custom-errors';
import { validatePublicKey } from '@@/os-user-ssh';
import { provisionOsAccount } from './provision-os';
// POST /api/users/:id/provision-linux — give an existing account its Linux side, or repair it.
//
// One route for what are the same operation from the owner's point of view:
//
// backfill an account created before per-user Linux accounts existed, or while the host was not set up
// for them, gets one now.
// retry the first attempt failed for a reason the owner has since fixed — the traversable-ancestor
// chmod being the one everybody hits once.
// re-key a new inbound public key replaces the old `authorized_keys`.
//
// Before this, the answer to all three was "delete the account and create it again", which throws away the
// password, the dashboards and everything else keyed to the row to redo a retryable side effect.
export const provisionLinuxHandler: Handler = async function (ctx) {
const id = Number(ctx.req.param('id'));
if (!Number.isInteger(id) || id < 1) throw errors.BAD_REQUEST('Invalid user id');
// The owner runs as the service user itself and its home is HOME_DIR — there is nothing to provision, and
// creating a second Linux account for it would be actively confusing.
if (id === OWNER_USER_ID) throw errors.BAD_REQUEST('The server owner already runs as the service account.');
const user = await getUserById(id);
if (!user) throw errors.NOT_FOUND('User not found');
if (!user.username) throw errors.BAD_REQUEST('This account has no username to name a Linux user after.');
// Optional. Absent means "leave authorized_keys as it is" rather than "remove inbound access": clearing a
// key should be a deliberate act, not the consequence of submitting a form with an empty field.
const body = (ctx.get('body') ?? {}) as Record<string, unknown>;
const raw = typeof body.sshPublicKey === 'string' ? body.sshPublicKey.trim() : '';
let inboundKey: string | null = null;
if (raw) {
const checked = validatePublicKey(raw);
if (!checked.ok) throw errors.BAD_REQUEST(`SSH public key: ${checked.error}`);
inboundKey = checked.key;
}
const result = await provisionOsAccount({
userId: user.id,
email: user.email,
username: user.username,
inboundKey,
});
// 200 with the error in the body rather than a 4xx: the interesting cases are partial. "The Linux account
// exists and is confined but the keys failed" is not an error the caller should treat as nothing having
// happened, and the UI has to be able to show both halves.
return ctx.json(result);
};
+118
View File
@@ -0,0 +1,118 @@
import { updateUser } from 'officerdb';
import { OS_USERS_ENABLED, ensureOsUser, osUserHome } from '@@/os-user';
import { provisionSshAccess } from '@@/os-user-ssh';
import { seedShellConfig } from '@@/os-user-shell';
import { provisionClaudeCli } from '@@/os-user-claude';
import { provisionRootlessDocker } from '@@/os-user-docker';
import { provisionUserDirs } from '@@/data-path';
// Giving an account its Linux side: the directory skeleton, the Linux user, the confinement, the keys.
//
// Shared by account creation and the retry route, because they are the same work and there are two moments
// it has to happen at — the same reasoning as app-store/members.ts. The list of reasons a retry is needed is
// not exotic:
//
// - the host was not set up for it when the account was made (`OFFICER_OS_USERS` off, no sudoers entry)
// - an ancestor directory was not traversable, which is the one everybody hits once
// - the owner wants to replace the inbound SSH key
//
// Before this existed the answer to all three was "delete the account and make it again", which loses the
// password, the dashboards and anything else keyed to the row — for what is really a retryable side effect.
export type OsProvisionOutcome = {
osUser: string | null;
sshPublicKey: string | null;
/** Null on success. Everything here is non-fatal — the platform account works regardless. */
error: string | null;
};
/**
* Idempotent. Every step underneath adopts what already exists: `useradd` is skipped for an account whose
* home already matches, the confinement re-applies mode bits, and an existing `id_ed25519` is kept rather
* than rotated (it has been added to Gitea by then).
*
* Never throws. A failure here must not undo or block a platform account that otherwise works, so the error
* is returned and the row keeps `osUser: null` which is exactly what an account created before this
* feature looks like, and which every consumer already handles.
*/
export async function provisionOsAccount(params: {
userId: number;
email: string;
username: string;
/** Inbound SSH key for `authorized_keys`. Already validated by the caller. */
inboundKey?: string | null;
}): Promise<OsProvisionOutcome> {
if (!OS_USERS_ENABLED) {
return { osUser: null, sshPublicKey: null, error: 'per-user Linux accounts are not enabled on this server' };
}
// First, because a missing skeleton is the reason `useradd --home-dir … -M` would have nothing to point at.
try {
provisionUserDirs(params.email);
} catch (ex) {
return {
osUser: null,
sshPublicKey: null,
error: `could not provision data directories: ${ex instanceof Error ? ex.message : String(ex)}`,
};
}
const account = await ensureOsUser({ email: params.email, username: params.username });
if (!account.ok) return { osUser: null, sshPublicKey: null, error: account.error };
// SSH after the account, because everything it writes lives inside a home that is not ours until
// `ensureOsUser` has chowned it away.
const ssh = await provisionSshAccess({
email: params.email,
osUser: account.osUser,
uid: account.uid,
gid: account.gid,
authorizedKey: params.inboundKey,
});
// The shell configuration. Late because its failure leaves nothing broken — the account works, the keys
// work, the terminal opens; it just opens with zsh's bare defaults.
const shell = await seedShellConfig({ email: params.email, uid: account.uid, gid: account.gid });
// Their own `claude`, in their own home. After the shell because it installs into a home that is not ours
// until `ensureOsUser` has chowned it away, and because the installer wants a working HOME.
//
// Only the binary. Logging in is the member's own act against their own Anthropic account — the platform
// cannot do it for them and must not try, because the alternative is lending them the owner's credential.
const claude = await provisionClaudeCli({ email: params.email, osUser: account.osUser });
// Their own rootless Docker daemon. Last, and the most tolerant of failure: a host without the uidmap
// package or a kernel that will not do rootless still gets a perfectly good account, minus containers.
//
// Provisioned for every member rather than behind a toggle, because "can I run a database to develop
// against" should not be an administrative request. The cost — one daemon and one image cache per member —
// is real and is written down in os-user-docker.ts.
const docker = await provisionRootlessDocker({
osUser: account.osUser,
uid: account.uid,
gid: account.gid,
home: osUserHome(params.email),
});
// The Linux account is recorded either way: it exists, it is confined, and a member's terminal can run as
// it. Only the keys are missing, and that is what the error says.
const sshPublicKey = ssh.ok ? ssh.publicKey : null;
await updateUser(params.userId, { osUser: account.osUser, osSshPublicKey: sshPublicKey });
// Reported in order of consequence, not in order of execution: no keys matters more than a plain prompt,
// which matters more than no containers. Only one is surfaced because the UI shows one line — the rest are
// in the log.
for (const step of [claude, shell, docker] as const) {
if (!step.ok) console.warn(`[users] ${params.email}: ${step.error}`);
}
const error = !ssh.ok
? ssh.error
: !claude.ok
? claude.error
: !shell.ok
? shell.error
: !docker.ok
? docker.error
: null;
return { osUser: account.osUser, sshPublicKey, error };
}
+6
View File
@@ -5,6 +5,8 @@ import { isSuperAdmin } from '@@/super-admin';
import * as errors from '@@/custom-errors'; import * as errors from '@@/custom-errors';
import { updateUserHandler } from './update-user'; import { updateUserHandler } from './update-user';
import { listUsersHandler, updateUserRoleHandler, deleteUserHandler } from './manage-users'; import { listUsersHandler, updateUserRoleHandler, deleteUserHandler } from './manage-users';
import { createUserHandler } from './create-user';
import { provisionLinuxHandler } from './provision-linux-route';
import { capabilityAdminRouter } from './capabilities-routes'; import { capabilityAdminRouter } from './capabilities-routes';
export const usersRouter = createRouter(); export const usersRouter = createRouter();
@@ -24,7 +26,11 @@ const ownerGate: MiddlewareHandler = async (ctx, next) => {
}; };
usersRouter.get('/', ownerGate, listUsersHandler); usersRouter.get('/', ownerGate, listUsersHandler);
// POST, not PUT — and worth noting they sit one line apart. `PUT /` is the selfService exception every
// account may call on itself; `POST /` creates somebody else and is the owner's alone.
usersRouter.post('/', ownerGate, createUserHandler);
usersRouter.patch('/:id/role', ownerGate, updateUserRoleHandler); usersRouter.patch('/:id/role', ownerGate, updateUserRoleHandler);
usersRouter.post('/:id/provision-linux', ownerGate, provisionLinuxHandler);
usersRouter.delete('/:id', ownerGate, deleteUserHandler); usersRouter.delete('/:id', ownerGate, deleteUserHandler);
// Which capabilities each role holds. Owner-gated inside its own router. // Which capabilities each role holds. Owner-gated inside its own router.
+68
View File
@@ -0,0 +1,68 @@
import { cp, rm, stat } from 'node:fs/promises';
import { join } from 'node:path';
// Copying a sidecar's own assets — its icon, and whatever else it ships — to where the browser can
// fetch them.
//
// src/servers/app-store/templates/<template>/assets/ what the sidecar ships
// public/plugins/<sidecar-id>/ where it is served from
//
// ── Why copied rather than served from where they live ──
//
// A sidecar that ships from its own repository has its assets wherever that repository was unpacked,
// which is not a path the web server can be taught at build time. Copying into one predictable place
// under `public/` means the serving rule is a single route (`/plugins/*` in server.tsx) that never has
// to know how many plugins exist or where any of them came from.
//
// It also means the assets are a property of the INSTALL rather than of the source tree: uninstall
// removes them, and a plugin that was never installed serves nothing.
//
// ── The boot-snapshot trap ──
//
// `publicRoutes` in server.tsx is built by globbing ./public at startup, so anything copied here after
// boot is invisible to it. That is why `/plugins/*` exists as a dynamic route — without it the first
// install of a plugin would show a broken image until the server was restarted.
/** Where a sidecar's shipped assets live in the source tree. */
export const assetSourceDir = (templateDir: string): string => join(templateDir, 'assets');
/** Where they are served from. Matches the `/plugins/*` route and the `image` paths in a UI manifest. */
export const assetPublicDir = (sidecarId: string): string => join(process.cwd(), 'public', 'plugins', sidecarId);
/** The URL a manifest should use for a shipped icon. */
export const iconUrl = (sidecarId: string): string => `/plugins/${sidecarId}/icon.png`;
const exists = async (path: string): Promise<boolean> => {
try {
await stat(path);
return true;
} catch {
return false;
}
};
/**
* Publish a sidecar's assets. Returns false when it ships none, which is not a failure most sidecars
* use a lucide glyph and have nothing to copy.
*
* Idempotent by overwriting: re-running an install republishes rather than erroring on a directory that
* already exists, which is what makes this safe inside a resumable step.
*/
export async function publishAssets(sidecarId: string, templateDir: string): Promise<boolean> {
const source = assetSourceDir(templateDir);
if (!(await exists(source))) return false;
await cp(source, assetPublicDir(sidecarId), { recursive: true, force: true });
return true;
}
/**
* Remove them on uninstall.
*
* Safe to delete, unlike everything else uninstall touches: these are copies of files that still exist
* in the sidecar's own source. Nothing a user made is in here, which is exactly why this is the one
* thing uninstall is allowed to remove.
*/
export async function unpublishAssets(sidecarId: string): Promise<void> {
await rm(assetPublicDir(sidecarId), { recursive: true, force: true });
}
+92
View File
@@ -0,0 +1,92 @@
import { listSidecarInstalls } from 'officerdb';
import { CATALOGUE, type CatalogueEntry } from './catalogue';
// Which features actually EXIST on this server right now — as opposed to which the account is permitted
// to use.
//
// ── Why this is separate from capabilities ──
//
// They answer different questions and combining them would get the owner wrong. A capability asks "may
// this account use Photos"; the owner bypasses that entirely and always may. Installation asks "is there
// a Photos on this machine at all", and the owner is as subject to it as anyone — installing nothing
// leaves nothing to use.
//
// Without this the dock on a fresh install lists Photos, Jellyfin, Transmission and the rest for the
// owner, each leading to a screen that reports itself unavailable. The features are meant to arrive when
// they are installed, not sit there greyed out from the start.
//
// ── Why it is computed here and not in the client ──
//
// The dock already reads one list from `/capabilities`. Making it read a second and intersect the two
// puts the rule in the UI, where a member's dock and an owner's dock can drift apart, and where a
// third-party plugin would have to be taught about it. Subtracting server-side keeps one answer.
/**
* Capability key the sidecar that has to be installed for it to mean anything.
*
* Includes `alsoServes`, because one sidecar can back more than one capability: Headscale serves both the
* owner's tailnet administration and a member enrolling their own device, and only listing the first left the
* second looking available on a machine that had no Headscale.
*/
const CAPABILITY_TO_SIDECAR = new Map(
CATALOGUE.flatMap((entry) =>
[entry.capability, ...(entry.alsoServes ?? [])].filter((key): key is string => !!key).map((key) => [key, entry.id]),
) as Array<[string, string]>,
);
export type Availability = {
/**
* UI manifests of the sidecars that ARE usable what the dock should show beyond the baseline.
*
* Sent with the capability answer rather than fetched separately so the dock has one source. Two
* requests would mean two moments, and a dock rendered between them shows either a tile for something
* uninstalled or nothing for something installed.
*/
manifests: Array<{ sidecarId: string; capability: string | null } & NonNullable<CatalogueEntry['ui']>>;
/** Capability keys whose sidecar is not installed, or is installed but disabled. */
unavailable: Set<string>;
/**
* True when install state could not be read.
*
* The caller then subtracts NOTHING. Same reasoning as `useCapabilities` failing open: a member seeing
* an icon that leads to an unavailable screen is a bad minute, while an owner whose whole dock vanished
* because a query failed is an incident. Absence of evidence is not evidence of absence.
*/
degraded: boolean;
};
/**
* What is missing on this server, by capability key.
*
* A sidecar that is installed but DISABLED counts as unavailable, deliberately. Disable stops the
* process and its container, so the feature genuinely does not work leaving its icon in place would
* make disable look broken rather than effective.
*/
export async function capabilityAvailability(): Promise<Availability> {
const unavailable = new Set<string>();
let installs;
try {
installs = await listSidecarInstalls();
} catch {
// Degraded: subtract nothing, and offer no manifests. The dock keeps its baseline rather than
// guessing, which is the same fail-open posture as useCapabilities.
return { unavailable, manifests: [], degraded: true };
}
const usable = new Set(
installs.filter((row) => row.status === 'installed' && row.enabled).map((row) => row.sidecarId),
);
for (const [capability, sidecarId] of CAPABILITY_TO_SIDECAR) {
if (!usable.has(sidecarId)) unavailable.add(capability);
}
const manifests = CATALOGUE.filter((e) => e.ui && usable.has(e.id)).map((e) => ({
sidecarId: e.id,
capability: e.capability,
...e.ui!,
}));
return { unavailable, manifests, degraded: false };
}
+186
View File
@@ -0,0 +1,186 @@
import { describe, expect, it } from 'bun:test';
import { CATALOGUE, byId } from './catalogue';
import { CAPABILITIES } from '../capabilities/registry';
// The catalogue is a hand-written list describing machinery that lives elsewhere, which is the shape of
// thing that rots silently. These tests pin it to the three sources it claims to agree with:
// ecosystem.config.cjs, the light profile, and the capability registry.
//
// The intent is that adding a sidecar to the estate and forgetting the app store FAILS HERE, rather than
// the sidecar being quietly uninstallable and nobody noticing for a release.
const full = (require('../../../ecosystem.config.cjs') as { apps: { name: string }[] }).apps.map((a) => a.name);
const light = (require('../../../ecosystem.light.config.cjs') as { apps: { name: string }[] }).apps.map((a) => a.name);
describe('the catalogue against the real estate', () => {
it('offers exactly the processes the light profile leaves out', () => {
// This is the definition of the app store: light is the baseline, everything else is installable.
const notInLight = full.filter((name) => !light.includes(name)).sort();
const offered = CATALOGUE.map((e) => e.process).sort();
expect(offered).toEqual(notInLight);
});
it('names a process that actually exists in the ecosystem', () => {
// A typo here would install nothing and report success.
for (const entry of CATALOGUE) expect(full).toContain(entry.process);
});
it('does not offer to install the baseline', () => {
// "Uninstall chat" is not a thing the store should be able to express.
for (const entry of CATALOGUE) expect(light).not.toContain(entry.process);
});
});
describe('entries are internally coherent', () => {
it('has unique ids', () => {
const ids = CATALOGUE.map((e) => e.id);
expect(new Set(ids).size).toBe(ids.length);
});
it('declares a compose template if and only if it can provision one', () => {
// Both directions matter: a provisioning entry with no template fails at install time, and a
// template on a non-provisioning entry is dead weight nobody will notice is unused.
for (const entry of CATALOGUE) {
if (entry.modes.includes('provisioned')) expect(entry.composeTemplate).toBeTruthy();
else expect(entry.composeTemplate).toBeUndefined();
}
});
it('asks for connection details exactly when it points at something existing', () => {
for (const entry of CATALOGUE) {
if (entry.modes.includes('existing')) expect(entry.existingFields?.length).toBeGreaterThan(0);
else expect(entry.existingFields).toBeUndefined();
}
});
it('requires a url when pointing at an existing instance', () => {
// Without one there is nothing to connect to, and the `existing` mode is meaningless.
for (const entry of CATALOGUE) {
if (!entry.modes.includes('existing')) continue;
const url = entry.existingFields?.find((f) => f.key === 'url');
expect(url?.required).toBe(true);
}
});
it('declares at least one mode', () => {
for (const entry of CATALOGUE) expect(entry.modes.length).toBeGreaterThan(0);
});
});
describe('capabilities it claims to back', () => {
it('names a capability that exists, or explicitly none', () => {
// `null` is a real answer — notify has no surface of its own — but a WRONG key would silently
// detach the store entry from the permission that governs the feature.
const keys = new Set(CAPABILITIES.map((c) => c.key));
for (const entry of CATALOGUE) {
if (entry.capability === null) continue;
expect(keys).toContain(entry.capability);
}
});
});
describe('byId', () => {
it('finds a known entry and returns undefined for anything else', () => {
expect(byId('photos')?.process).toBe('officer-photos');
expect(byId('not-a-sidecar')).toBeUndefined();
});
});
describe('member provisioning is declared for every entry', () => {
it('declares how members get access', () => {
// Undeclared would silently mean "no account for anyone", which is invisible until a member
// reports that a feature the owner can see does nothing for them.
for (const entry of CATALOGUE) expect(['accounts', 'invite', 'none']).toContain(entry.members);
});
it('never promises accounts for a single-tenant daemon', () => {
// Transmission and slskd have no user concept; claiming otherwise would make the installer try to
// create accounts against an API that does not exist.
for (const id of ['transmission', 'slskd']) expect(byId(id)!.members).toBe('none');
});
it('marks the vault as invite-only, because it cannot be otherwise', () => {
// Vaultwarden derives its encryption key from the master password. A credential we could mint is a
// vault we could read, so 'accounts' here would be a security defect rather than a feature.
expect(byId('vault')!.members).toBe('invite');
});
});
describe('the UI manifest each sidecar carries', () => {
it('declares one, unless it has no interface of its own', () => {
// notify is the only exception: it produces notifications FOR other features and has no screen.
for (const entry of CATALOGUE) {
if (entry.id === 'notify') expect(entry.ui).toBeUndefined();
else expect(entry.ui).toBeDefined();
}
});
it('names an icon rather than importing one', () => {
// A manifest has to survive being JSON from marketplace.officer.dev. A lucide component cannot make
// that trip; a name can, and resolveIcon already maps names to glyphs.
for (const entry of CATALOGUE) {
if (!entry.ui) continue;
const hasGlyph = typeof entry.ui.icon === 'string';
const hasImage = typeof entry.ui.image === 'string';
expect(hasGlyph || hasImage).toBe(true);
}
});
it('includes its rootRoute among its routes', () => {
// The dock links to rootRoute and the guard matches `routes`. If the root is missing from the list,
// the tile appears and leads somewhere the guard refuses.
for (const entry of CATALOGUE) {
if (!entry.ui) continue;
expect(entry.ui.routes).toContain(entry.ui.rootRoute);
}
});
it('claims routes that the capability registry agrees it owns', () => {
// The manifest drives the dock; the registry drives the server-side guard. If they disagree, a tile
// appears for a route the account is refused — or worse, a route is guarded by nothing.
const byKey = new Map(CAPABILITIES.map((c) => [c.key, c]));
for (const entry of CATALOGUE) {
if (!entry.ui || !entry.capability) continue;
const declared = byKey.get(entry.capability)?.routes ?? [];
for (const route of entry.ui.routes) expect(declared).toContain(route);
}
});
it('does not have two sidecars claiming the same root route', () => {
const roots = CATALOGUE.filter((e) => e.ui).map((e) => e.ui!.rootRoute);
expect(new Set(roots).size).toBe(roots.length);
});
});
describe('the "do you already have one?" question', () => {
it('is always offered by anything that can provision', () => {
// The rule: a sidecar that would start a container must first let the user point at one they
// already run. Offering only 'provisioned' means someone with a working Immich gets a second one,
// and only finds out when two libraries disagree.
//
// Enforced here rather than trusted to the UI, because a new entry added later would otherwise
// silently skip the question.
for (const entry of CATALOGUE) {
if (!entry.modes.includes('provisioned')) continue;
expect(entry.modes).toContain('existing');
}
});
it('offers "existing" first, so the prompt leads with it', () => {
// `modes` is the order the UI presents them in. Leading with "provision one for me" invites a
// second instance from someone who already has one.
for (const entry of CATALOGUE) {
if (!entry.modes.includes('existing')) continue;
expect(entry.modes[0]).toBe('existing');
}
});
it('asks for a URL wherever an existing instance can be named', () => {
// Without one there is nothing to connect to, and "I already have it" cannot be answered.
for (const entry of CATALOGUE) {
if (!entry.modes.includes('existing')) continue;
expect(entry.existingFields?.some((f) => f.key === 'url' && f.required)).toBe(true);
}
});
});
+444
View File
@@ -0,0 +1,444 @@
// What the app store can install, and what installing each one actually requires.
//
// This is the catalogue, not the state — `sidecar_installs` records what the owner has done, this
// records what is on offer. Everything the installer branches on lives here so the installer itself has
// no per-sidecar knowledge, the same restraint `create-proxy.ts` keeps on the proxy side.
//
// ── The entries are derived from a fact, not invented ──
//
// The fourteen below are exactly the processes in `ecosystem.config.cjs` that the light profile
// excludes. That is not a coincidence to be maintained by hand: `catalogue.test.ts` asserts it, so a
// sidecar added to the estate and forgotten here fails a test rather than being quietly uninstallable.
//
// Light itself (officer, the agent, the anthropic proxy, opencode, pty, gitea) is not in the catalogue.
// Those are the baseline — chat, terminal, file browser — and there is no meaningful "uninstall chat".
/**
* How an install can be satisfied. A sidecar may support more than one, and the prompt is the fork:
* "do you already have one, or shall we start one for you?"
*/
export type InstallMode =
/** Point at an instance the user already runs, here or elsewhere. We provision nothing. */
| 'existing'
/** Render our compose template and start it. The connection is then known without asking. */
| 'provisioned'
/** Nothing to reach. Credentials or local configuration only. */
| 'config';
/**
* One field the installer asks for before it can finish.
*
* A `url` placeholder should show a REMOTE example. The instance may be on another host, behind a
* reverse proxy, on a non-standard port, or all three a placeholder reading `http://localhost:8096`
* quietly teaches that it must be local and on the usual port, which is wrong often enough to matter.
*/
export type ConfigField = {
key: string;
label: string;
/** `secret` is written encrypted and never read back to the client. */
type: 'url' | 'text' | 'secret';
required: boolean;
placeholder?: string;
help?: string;
};
/**
* How a sidecar presents itself in the app: the dock tile and the routes it owns.
*
* Carried by the sidecar rather than hardcoded in the shell, because a sidecar that ships from its own
* repository has to be able to say what it looks like. `ALL_DOCK_ITEMS` in Dock.tsx is the current
* hardcoded list; the intent is that it becomes derived from installed manifests, so a feature appears
* in the dock when it is installed and leaves when it is removed, with nothing in the shell to update.
*
* The icon is a NAME, not an imported component. A manifest has to survive being JSON from
* marketplace.officer.dev, and a lucide import cannot. `resolveIcon` already maps names to glyphs for
* exactly this reason.
*/
export type UiManifest = {
/** Dock label. Often shorter than the catalogue `label` — "Video" for Jellyfin. */
name: string;
/** A lucide icon name, resolved at render. */
icon?: string;
/**
* An image asset instead of a glyph, for a service with its own mark. Real PNGs are coming, and this
* is the field they arrive in.
*
* `/slskd.png` works today because it sits in the platform's `public/`. A marketplace plugin cannot
* put a file there, so one thing has to be decided before third-party icons ship: whether the bytes
* are fetched from the marketplace (simple, but the dock loses its icons offline), served by us from
* the sidecar's own directory (works offline, needs a route and a cache header), or inlined as a data
* URI (no fetch, but every manifest carries it and it is unpleasant at 512px).
*
* Whichever wins, this stays a STRING the renderer resolves never an import for the same reason
* `icon` is a name: a manifest has to survive being JSON.
*/
image?: string;
/** Tile colour. */
color: string;
/** Where the dock tile goes, and the prefix the route guard matches. */
rootRoute: string;
/**
* Every frontend route this sidecar owns, `rootRoute` included.
*
* Separate from `rootRoute` because a feature can own more than one path the capability registry
* already lists `/caldav` and `/dav` together and the guard needs all of them while the dock needs
* exactly one.
*/
routes: string[];
/**
* Additional dock tiles, for the rare sidecar that presents as more than one thing.
*
* CalDAV is the only case today: one sidecar, but Calendar and Contacts are separate features to
* anyone using them, and collapsing them into one tile to keep the model tidy would make the app
* worse. Every route here must still appear in `routes`.
*/
extraTiles?: Array<{ name: string; icon?: string; image?: string; color: string; route: string }>;
};
export type CatalogueEntry = {
/** Stable id. Matches `sidecar_installs.sidecar_id` and `service_connections.service` where both exist. */
id: string;
/** How it appears in the app. Absent for a sidecar with no UI of its own, like notify. */
ui?: UiManifest;
/** The PM2 process to start and stop. Must exist in ecosystem.config.cjs. */
process: string;
label: string;
/** One line, shown in the store listing. */
summary: string;
/** Which of the three shapes this sidecar supports, in the order the UI should offer them. */
modes: InstallMode[];
/**
* The capability this sidecar backs, from `capabilities/registry.ts`. Null where the sidecar has no
* user-facing surface of its own (notify produces notifications for other features).
*
* This is also the key the sidecar's dock manifest is filtered by, which is why it is one value and not a
* list a tile belongs to one feature.
*/
capability: string | null;
/**
* Other capabilities that stop working when this sidecar is absent, for availability only.
*
* Headscale is the case: one sidecar serves both `headscale` (administering the tailnet, owner-only) and
* `vpn` (a member enrolling their own device). With only `capability` to go on, `vpn` was never subtracted,
* so the Permissions screen offered it on a machine with no Headscale at all a grant that would have
* produced a refusal the owner could not account for.
*/
alsoServes?: string[];
/**
* Asked when the user picks `existing`. Skipped entirely for `provisioned`, where we already know the
* answers because we wrote the compose file.
*/
existingFields?: ConfigField[];
/** Asked for `config` installs, which have no instance to point at. */
configFields?: ConfigField[];
/** Name of the compose template under `app-store/templates/`. Required iff `modes` includes 'provisioned'. */
composeTemplate?: string;
/**
* Whether members get their own account on this service, and how far that can be automated.
*
* 'accounts' an admin API can create the user AND mint a credential, so provisioning is fully
* transparent: the member simply finds the feature working. Immich, Jellyfin, Gitea,
* Memos.
* 'invite' an account can be created but a usable credential cannot, and that is a property of
* the service rather than a gap in ours. Vaultwarden is end-to-end encrypted: the
* master password derives the encryption key, so a credential we could mint would mean
* a vault we could read. The member is invited and sets their own password.
* 'none' a single-tenant daemon with no user concept. Transmission, slskd. Access is mediated
* entirely by Officer, which is already how it works.
*
* Read at two moments, not one: when the service is installed (for every member who already exists)
* and when a member is added (for every service already installed). Only handling the first is the
* classic thing that works on day one and silently rots.
*/
members: 'accounts' | 'invite' | 'none';
/**
* A host requirement that is NOT derivable from `modes`. Shown instead of the install button rather
* than failing halfway through.
*
* Docker deliberately does not appear here: needing it is exactly "this entry can provision", which
* `modes` already says. `preflight.needsDocker` derives it, so the two cannot disagree.
*/
requires?: 'linux-display';
};
export const CATALOGUE: CatalogueEntry[] = [
// ── Point at something you already run, or let us start one ────────────────────────────────────────
{
id: 'photos',
ui: { name: 'Photos', icon: 'Images', color: '#10b981', rootRoute: '/photos', routes: ['/photos'] },
process: 'officer-photos',
label: 'Photos',
summary: 'Your Immich library — browse, search, upload from the phone',
members: 'accounts',
modes: ['existing', 'provisioned'],
capability: 'photos',
composeTemplate: 'immich',
existingFields: [
{ key: 'url', label: 'Immich URL', type: 'url', required: true, placeholder: 'https://photos.example.com' },
{
key: 'secret',
label: 'API key',
type: 'secret',
required: true,
help: 'Immich → Account Settings → API Keys. Create it with all permissions: a scoped key returns 403 per route, which reads as a broken feature.',
},
],
},
{
id: 'jellyfin',
ui: { name: 'Video', icon: 'Clapperboard', color: '#a855f7', rootRoute: '/jellyfin', routes: ['/jellyfin'] },
process: 'officer-jellyfin',
label: 'Jellyfin',
summary: 'Films and shows, with a player that handles direct, HLS and progressive',
members: 'accounts',
modes: ['existing', 'provisioned'],
capability: 'jellyfin',
composeTemplate: 'jellyfin',
existingFields: [
{ key: 'url', label: 'Jellyfin URL', type: 'url', required: true, placeholder: 'https://jellyfin.example.com' },
{ key: 'secret', label: 'Access token', type: 'secret', required: true },
],
},
{
id: 'memos',
ui: { name: 'Memos', icon: 'NotebookPen', color: '#eab308', rootRoute: '/memos', routes: ['/memos'] },
process: 'officer-memos',
label: 'Memos',
summary: 'Quick notes, tagged and searchable',
members: 'accounts',
modes: ['existing', 'provisioned'],
capability: 'memos',
composeTemplate: 'memos',
existingFields: [
{ key: 'url', label: 'Memos URL', type: 'url', required: true },
{ key: 'secret', label: 'Access token', type: 'secret', required: true },
],
},
{
id: 'invoiceshelf',
ui: { name: 'Invoices', icon: 'Receipt', color: '#0891b2', rootRoute: '/invoices', routes: ['/invoices'] },
process: 'officer-invoiceshelf',
label: 'Invoices',
summary: 'InvoiceShelf — clients, estimates and invoices',
members: 'accounts',
modes: ['existing', 'provisioned'],
capability: 'invoices',
composeTemplate: 'invoiceshelf',
existingFields: [
{ key: 'url', label: 'InvoiceShelf URL', type: 'url', required: true },
{ key: 'secret', label: 'API token', type: 'secret', required: true },
],
},
{
id: 'vault',
ui: { name: 'Vault', icon: 'KeyRound', color: '#175ddc', rootRoute: '/vault', routes: ['/vault'] },
process: 'officer-vault',
label: 'Vault',
summary: 'Vaultwarden — passwords, reachable by the Bitwarden apps',
members: 'invite',
modes: ['existing', 'provisioned'],
// No capability entry exists for this one, and the reason is about CREDENTIALS, not routing.
//
// Every request still goes through us: `/api/vault` is mounted on vaultRouter and forwarded by the
// officer-vault sidecar to Vaultwarden. The Bitwarden clients never reach Vaultwarden directly.
//
// What they do NOT carry is a platform JWT — they present their own Vaultwarden bearer token — so
// `userMiddleware` would 401 them and a capability lookup would have no account to resolve. Hence
// `/vault` sits in EXEMPT_API_PREFIXES, gated by origin scoping and Vaultwarden's own auth instead.
// The install still governs whether the sidecar runs at all.
capability: null,
composeTemplate: 'vaultwarden',
existingFields: [{ key: 'url', label: 'Vaultwarden URL', type: 'url', required: true }],
},
{
id: 'transmission',
ui: {
name: 'Transmission',
icon: 'ArrowDownUp',
color: '#e11d48',
rootRoute: '/transmission',
routes: ['/transmission'],
},
process: 'officer-transmission',
label: 'Transmission',
summary: 'Torrents, with the daemon Officer talks to over RPC',
members: 'none',
modes: ['existing', 'provisioned'],
capability: 'transmission',
composeTemplate: 'transmission',
existingFields: [
{
key: 'url',
label: 'Transmission URL',
type: 'url',
required: true,
placeholder: 'https://transmission.example.com or http://10.0.0.5:9091',
},
{
key: 'path',
label: 'RPC path',
type: 'text',
required: false,
placeholder: '/transmission/rpc',
help: 'Only differs behind a reverse proxy.',
},
{
key: 'username',
label: 'RPC username',
type: 'text',
required: false,
help: 'Usually blank — Transmission is normally run with no RPC auth.',
},
{ key: 'secret', label: 'RPC password', type: 'secret', required: false },
],
},
{
id: 'slskd',
ui: {
name: 'Soulseek',
image: '/plugins/slskd/icon.png',
color: '#ffffff',
rootRoute: '/soulseek',
routes: ['/soulseek'],
},
process: 'officer-slskd',
label: 'Soulseek',
summary: 'slskd — search and download from the Soulseek network',
members: 'none',
modes: ['existing', 'provisioned'],
capability: 'soulseek',
composeTemplate: 'slskd',
existingFields: [
{ key: 'url', label: 'slskd URL', type: 'url', required: true },
{ key: 'secret', label: 'API key', type: 'secret', required: true },
],
},
{
id: 'caldav',
ui: {
name: 'Calendar',
icon: 'CalendarDays',
color: '#3b82f6',
rootRoute: '/calendar',
routes: ['/calendar', '/contacts'],
},
process: 'officer-caldav',
label: 'Calendar',
summary: 'Radicale — calendars and contacts over CalDAV/CardDAV',
members: 'accounts',
modes: ['existing', 'provisioned'],
capability: 'calendar',
composeTemplate: 'radicale',
existingFields: [{ key: 'url', label: 'CalDAV URL', type: 'url', required: true }],
},
{
id: 'gitea',
ui: { name: 'Gitea', icon: 'GitBranch', color: '#34d399', rootRoute: '/gitea', routes: ['/gitea'] },
process: 'officer-gitea',
label: 'Gitea',
summary: 'Repositories, issues and pull requests from your own Gitea account',
// 'none' because there is nothing for the INSTALLER to provision, not because Gitea is single-tenant —
// it is the most per-user service here. The owner's connection carries the instance URL; every member
// adds their own access token from /gitea and acts only as themselves upstream, with Gitea's own
// permissions as the second and real gate. A provisioner could not do that part for them without
// holding an admin token and minting tokens on their behalf, which is more authority than this needs.
members: 'none',
// `existing` only, and there is no compose template. Gitea here is always something the owner already
// runs — on this machine, on another, or hosted. Provisioning one would mean owning the migration,
// backup and upgrade story for a service that is nobody's side feature.
modes: ['existing'],
capability: 'gitea',
existingFields: [
{ key: 'url', label: 'Gitea URL', type: 'url', required: true, placeholder: 'https://gitea.example.com' },
{
key: 'secret',
label: 'Your access token',
type: 'secret',
required: true,
help: 'A personal access token from your own Gitea account. Members add their own from /gitea.',
},
],
},
{
id: 'headscale',
ui: { name: 'Headscale', icon: 'Network', color: '#818cf8', rootRoute: '/headscale', routes: ['/headscale'] },
process: 'officer-headscale',
label: 'Headscale',
summary: 'Your own tailnet control plane',
members: 'none',
modes: ['existing'],
capability: 'headscale',
// A member enrolling their own device is the same sidecar. See `alsoServes`.
alsoServes: ['vpn'],
existingFields: [
{ key: 'url', label: 'Headscale URL', type: 'url', required: true },
{ key: 'secret', label: 'API key', type: 'secret', required: true },
],
},
// ── Nothing to reach: configuration only ───────────────────────────────────────────────────────────
{
id: 'email',
ui: { name: 'Email', icon: 'Mail', color: '#ef4444', rootRoute: '/email', routes: ['/email'] },
process: 'officer-email',
label: 'Email',
summary: 'Your IMAP accounts, synced and searchable',
members: 'none',
modes: ['config'],
capability: 'email',
// Deliberately empty: accounts are added from /email, which already has a working multi-account
// form. Duplicating it here would be a second place to maintain the same credentials.
configFields: [],
},
{
id: 'music',
ui: { name: 'Music', icon: 'Music', color: '#22c55e', rootRoute: '/music', routes: ['/music'] },
process: 'officer-music',
label: 'Music',
summary: 'Index and play the library on this machine',
members: 'none',
modes: ['config'],
capability: 'music',
configFields: [{ key: 'library', label: 'Music folder', type: 'text', required: true, placeholder: '~/Music' }],
},
{
id: 'wallet',
ui: { name: 'Wallet', icon: 'Bitcoin', color: '#f7931a', rootRoute: '/wallet', routes: ['/wallet'] },
process: 'officer-wallet',
label: 'Wallet',
summary: 'Bitcoin and Lightning, with keys held by the sidecar alone',
members: 'none',
modes: ['config'],
capability: 'wallet',
configFields: [],
},
{
id: 'notify',
process: 'officer-notify',
label: 'Notifications',
summary: 'Push to your phone when a job finishes or a turn needs you',
members: 'none',
modes: ['config'],
capability: 'notify',
configFields: [],
},
{
id: 'vnc',
ui: { name: 'Desktop', icon: 'MonitorSmartphone', color: '#ec4899', rootRoute: '/desktop', routes: ['/desktop'] },
process: 'officer-vnc',
label: 'Desktop',
summary: 'Mirror this machines display in the browser',
members: 'none',
modes: ['config'],
capability: 'desktop',
// x11vnc against an Xorg display. There is nothing to mirror on a headless box or on macOS, so the
// store should say so rather than install something that starts and immediately fails.
requires: 'linux-display',
configFields: [],
},
];
export const byId = (id: string): CatalogueEntry | undefined => CATALOGUE.find((e) => e.id === id);
/** Every catalogue id, for validating a request before it reaches the installer. */
export const CATALOGUE_IDS: ReadonlySet<string> = new Set(CATALOGUE.map((e) => e.id));
+71
View File
@@ -0,0 +1,71 @@
import { stat } from 'node:fs/promises';
import { join } from 'node:path';
// Starting, stopping and removing the containers behind a provisioned sidecar.
//
// ── The one rule ──
//
// `down`, never `down -v`. Uninstall removes containers; it does not remove data, and there is no
// option here that does. A user uninstalling Photos is saying "stop running this", not "delete my
// library", and for Immich or Jellyfin getting that wrong once is unrecoverable.
//
// The bind-mount convention makes that structural rather than a rule to remember: the data lives on the
// host inside the service directory, so `-v` — which only removes NAMED volumes — could not delete it
// even if someone added the flag. This module simply never gives them the chance.
//
// ── Why every function tolerates a missing directory ──
//
// Three of the four call sites can legitimately arrive with nothing there: an `existing` install never
// provisioned anything, a failed install may have died before writing the compose file, and a resumed
// uninstall may be re-running a step that already succeeded. Treating those as errors would make a row
// impossible to uninstall, which is the one state a user cannot get themselves out of.
export type ComposeResult = { ok: true; ran: boolean } | { ok: false; error: string };
async function hasCompose(serviceDir: string): Promise<boolean> {
try {
await stat(join(serviceDir, 'docker-compose.yaml'));
return true;
} catch {
return false;
}
}
async function compose(serviceDir: string, args: string[]): Promise<ComposeResult> {
if (!(await hasCompose(serviceDir))) return { ok: true, ran: false };
const proc = Bun.spawn(['docker', 'compose', '--project-directory', serviceDir, ...args], {
stdout: 'pipe',
stderr: 'pipe',
});
const [out, err] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
const code = await proc.exited;
if (code !== 0) return { ok: false, error: `${err || out}`.trim() || `docker compose ${args[0]} exited ${code}` };
return { ok: true, ran: true };
}
/**
* Bring the containers up. Used by enable, and by a resumed install whose containers were stopped.
*
* `up -d` rather than `start`: `start` only works on containers that already exist, and after a
* `down` they do not. `up -d` covers both, and is a no-op against an unchanged compose file.
*/
export const composeUp = (serviceDir: string): Promise<ComposeResult> => compose(serviceDir, ['up', '-d']);
/**
* Stop them, leaving them defined.
*
* Disable stops the container as well as the sidecar there is no reason to leave Immich holding
* memory while Photos is switched off and `stop` rather than `down` is what makes re-enabling
* instant rather than a fresh `up`.
*/
export const composeStop = (serviceDir: string): Promise<ComposeResult> => compose(serviceDir, ['stop']);
/**
* Remove containers and networks. Uninstall only.
*
* No `-v`, and no `--rmi`: the images are shared and expensive to re-pull, and the volumes are the
* user's data. Both are deliberate omissions rather than oversights.
*/
export const composeDown = (serviceDir: string): Promise<ComposeResult> => compose(serviceDir, ['down']);
+151
View File
@@ -0,0 +1,151 @@
import { join } from 'node:path';
import { getOwnerUser, saveServiceConnection, getServiceConnection } from 'officerdb';
import type { InstallEffects, StepContext } from './installer';
import { preflight } from './preflight';
import { runSetupScript } from './run-script';
import { startProcess } from './pm2';
import { serviceDir } from './paths';
import { publishAssets } from './assets';
import { checkServiceUrl } from './url';
// The installer's steps, wired to the actual world.
//
// Kept apart from `installer.ts` on purpose: that file is the machine — ordering, resume, blocking —
// and is tested with these replaced by spies. This file is the thin part that genuinely touches PM2,
// Postgres and the filesystem, and is deliberately boring enough to read in one sitting.
/** Where the platform lives, for `pm2 start ecosystem.config.cjs`. */
const PLATFORM_DIR = process.cwd();
const TEMPLATES_DIR = join(import.meta.dir, 'templates');
export function createEffects(): InstallEffects {
return {
preflight: (entry, mode) => preflight(entry, mode),
async provision(ctx: StepContext) {
const template = ctx.entry.composeTemplate;
// planSteps only emits `provision` for a provisioned install, and the catalogue test asserts
// every such entry has a template — so reaching here without one is a broken catalogue, not a
// user error, and it should say so rather than fail obscurely inside the runner.
if (!template) throw new Error(`${ctx.entry.id} has no compose template but was asked to provision`);
const result = await runSetupScript({
sidecarId: ctx.entry.id,
templateDir: join(TEMPLATES_DIR, template),
env: ctx.values,
log: ctx.log,
});
if (!result.ok) throw new Error(result.error);
return { results: result.results, composeDir: result.serviceDir };
},
async connect(ctx: StepContext) {
// Validated and normalised before anything is stored. What the user typed may be a bare hostname,
// may carry a trailing slash that doubles a separator later, or may hide a token in a query
// string — and none of those are visible again once written.
const raw = ctx.values.url;
if (raw) {
const checked = checkServiceUrl(raw);
if (!checked.ok) {
return { status: 'blocked', reason: `${ctx.entry.label}: ${checked.error}` };
}
ctx.values.url = checked.url;
}
const url = ctx.values.url;
// A provisioned install knows its own URL because the setup script printed it. An `existing` one
// was given it in the form. Neither having produced one means we cannot reach the service, and
// the honest answer is to stop rather than write a row that points nowhere.
if (!url) {
return {
status: 'blocked',
reason: `${ctx.entry.label} is running, but no connection URL was provided.`,
};
}
// Shape 3 from templates/README.md: Immich, Jellyfin and Memos mint their API key in their own
// UI, so a provisioned install is up and healthy but not yet connectable. That is a pause, not a
// failure — see markBlocked.
const needsSecret = ctx.entry.existingFields?.some((f) => f.key === 'secret' && f.required);
if (needsSecret && !ctx.values.secret) {
return {
status: 'blocked',
reason: `${ctx.entry.label} needs an API key, which only its own interface can create.`,
completeAt: url,
};
}
const owner = await getOwnerUser();
if (!owner) throw new Error('no owner account');
// Never overwrite a connection the owner already has, unless they said so.
//
// Found the hard way: installing a sidecar that was already configured replaced its connection
// row with the new install's, silently repointing a working service at something else. On a fresh
// machine that is harmless; on one with an existing setup it breaks a feature that was fine, and
// the only symptom is that it stops working.
//
// Blocking rather than failing, because there is a sensible answer and the user is the only one
// who has it: keep what is there, or replace it deliberately.
const current = await getServiceConnection(owner.id, ctx.entry.id as never);
if (current && current.url && current.url !== url && ctx.values.replaceConnection !== 'true') {
return {
status: 'blocked',
reason:
`${ctx.entry.label} is already connected to ${current.url}. Installing would repoint it to ` +
`${url}. Keep the existing connection, or reinstall with "replace connection" to change it.`,
completeAt: current.url,
};
}
// The OWNER's row: it carries the URL and IS the instance. Members get their own rows later, with
// a null url that inherits this one — see members.ts and the service_connections schema.
await saveServiceConnection({
userId: owner.id,
service: ctx.entry.id as never,
url,
username: ctx.values.username || null,
secret: ctx.values.secret || null,
path: ctx.values.path || null,
});
ctx.log(`connection saved for ${ctx.entry.label}`);
return { status: 'done' };
},
async applySchema(ctx: StepContext) {
// A no-op today, and honestly so. Every sidecar's tables still ship in the platform's single
// Drizzle schema and arrive together via `bun db:push`, so by the time an install runs they
// already exist. The step is in the plan because per-sidecar schema is the direction — a
// marketplace plugin cannot ship a table into a schema it does not own — and adding the step
// later would mean revisiting every install that had already recorded its progress without it.
ctx.log('schema: already present (platform-wide schema; per-sidecar schema is a later phase)');
},
async publishAssets(ctx: StepContext) {
// Every install, not just provisioned ones: an `existing` Photos still needs its icon in the dock.
// The template directory is where a sidecar ships its assets, and a sidecar with none is the
// common case rather than an error.
const template = ctx.entry.composeTemplate ?? ctx.entry.id;
const published = await publishAssets(ctx.entry.id, join(TEMPLATES_DIR, template));
ctx.log(published ? `assets published to /plugins/${ctx.entry.id}/` : 'assets: none shipped');
},
async startProcess(ctx: StepContext) {
const result = await startProcess(ctx.entry.process, PLATFORM_DIR);
if (!result.ok) throw new Error(`could not start ${ctx.entry.process}: ${result.error}`);
ctx.log(`${ctx.entry.process} started`);
},
async provisionMembers(ctx: StepContext) {
// Nothing implements MemberProvisioner yet — each sidecar will, beside its own code, rather than
// in a switch here. Logged rather than silently skipped so that a member who cannot see a service
// has a trail explaining why.
ctx.log(`members: no provisioner registered for ${ctx.entry.id} yet — members will need manual access`);
},
};
}
/** Where a provisioned service's compose file lives, for the caller to record. */
export const composeDirFor = (sidecarId: string): string => serviceDir(sidecarId);
+211
View File
@@ -0,0 +1,211 @@
import { describe, expect, it } from 'bun:test';
import { planSteps, runInstall, type InstallEffects, type StepName } from './installer';
import { byId } from './catalogue';
// The whole install machine, tested without Docker, Postgres, PM2 or an Immich to talk to — which is
// the reason the effects are injected. What is being pinned here is the behaviour that only shows up
// when something goes wrong: resume, blocking, and not doing work twice.
const photos = byId('photos')!; // existing|provisioned, members: 'accounts'
const transmission = byId('transmission')!; // existing|provisioned, members: 'none'
const email = byId('email')!; // config only
/** Records what was actually called, so a test can assert on absence as well as presence. */
function spyEffects(over: Partial<InstallEffects> = {}) {
const calls: string[] = [];
const effects: InstallEffects = {
preflight: async () => {
calls.push('preflight');
return { ok: true };
},
provision: async () => {
calls.push('provision');
return { results: { url: 'http://127.0.0.1:18091' }, composeDir: '/root/dockers/x' };
},
connect: async () => {
calls.push('connect');
return { status: 'done' };
},
applySchema: async () => void calls.push('schema'),
publishAssets: async () => void calls.push('assets'),
startProcess: async () => void calls.push('process'),
provisionMembers: async () => void calls.push('members'),
...over,
};
return { effects, calls };
}
describe('planSteps', () => {
it('never provisions when pointing at an instance the user already runs', () => {
// The guarantee that matters: choosing 'existing' cannot start a container. Enforced here rather
// than remembered at each call site.
expect(planSteps(photos, 'existing')).not.toContain('provision');
expect(planSteps(photos, 'provisioned')).toContain('provision');
});
it('omits the members step for a service with no user concept', () => {
// Otherwise a Transmission install reports a members step that did nothing, which reads as a
// silent failure to anyone debugging why a member has no access.
expect(planSteps(transmission, 'provisioned')).not.toContain('members');
expect(planSteps(photos, 'provisioned')).toContain('members');
});
it('has nothing to connect for a config-only install', () => {
expect(planSteps(email, 'config')).toEqual(['preflight', 'schema', 'assets', 'process']);
});
it('always starts with preflight', () => {
// Checking the host before anything is written is the whole reason a half-install is avoidable.
for (const mode of ['existing', 'provisioned', 'config'] as const) {
expect(planSteps(photos, mode)[0]).toBe('preflight');
}
});
});
describe('a clean run', () => {
it('executes the plan in order and reports installed', async () => {
const { effects, calls } = spyEffects();
const out = await runInstall({ entry: photos, mode: 'provisioned', values: {}, effects });
expect(out.status).toBe('installed');
expect(calls).toEqual(['preflight', 'provision', 'connect', 'schema', 'assets', 'process', 'members']);
});
it('feeds one steps results forward to the next', async () => {
// `provision` discovers the URL that `connect` writes down two steps later. Without this the
// installer would have to ask the user for something it already knows.
let seen: Record<string, string> = {};
const { effects } = spyEffects({
connect: async (ctx) => {
seen = { ...ctx.values };
return { status: 'done' };
},
});
await runInstall({ entry: photos, mode: 'provisioned', values: { given: 'yes' }, effects });
expect(seen.url).toBe('http://127.0.0.1:18091');
expect(seen.composeDir).toBe('/root/dockers/x');
expect(seen.given).toBe('yes');
});
it('does not mutate the callers values', async () => {
const values = { given: 'yes' };
const { effects } = spyEffects();
await runInstall({ entry: photos, mode: 'provisioned', values, effects });
expect(values).toEqual({ given: 'yes' });
});
});
describe('resuming', () => {
it('skips what an earlier attempt already did', async () => {
// The point of persisting completedSteps: a resume must not provision a second container.
const { effects, calls } = spyEffects();
const done: StepName[] = ['preflight', 'provision', 'connect'];
const out = await runInstall({ entry: photos, mode: 'provisioned', values: {}, completed: done, effects });
expect(out.status).toBe('installed');
expect(calls).toEqual(['schema', 'assets', 'process', 'members']);
expect(calls).not.toContain('provision');
});
it('reports every completed step, including the ones it skipped', async () => {
const { effects } = spyEffects();
const out = await runInstall({
entry: photos,
mode: 'provisioned',
values: {},
completed: ['preflight'],
effects,
});
expect(out.completed).toEqual(['preflight', 'provision', 'connect', 'schema', 'assets', 'process', 'members']);
});
});
describe('blocking on a human', () => {
it('stops without failing, and does NOT mark the blocking step done', async () => {
// Immich: the container is up and healthy, and only its own UI can mint an API key. Recording
// `connect` as complete would mean a resume skipped the very step that is waiting.
const { effects, calls } = spyEffects({
connect: async () => ({ status: 'blocked', reason: 'Needs an API key', completeAt: 'http://x/keys' }),
});
const out = await runInstall({ entry: photos, mode: 'provisioned', values: {}, effects });
expect(out.status).toBe('blocked');
if (out.status !== 'blocked') throw new Error('unreachable');
expect(out.at).toBe('connect');
expect(out.completeAt).toBe('http://x/keys');
expect(out.completed).toEqual(['preflight', 'provision']);
// Everything after the block is untouched — no process started against a service we cannot reach.
expect(calls).not.toContain('process');
});
it('re-runs the blocking step on resume, once the human has answered', async () => {
const { effects, calls } = spyEffects();
const out = await runInstall({
entry: photos,
mode: 'provisioned',
values: { secret: 'now-provided' },
completed: ['preflight', 'provision'],
effects,
});
expect(out.status).toBe('installed');
expect(calls).toContain('connect');
});
});
describe('failing', () => {
it('stops at the failing step and keeps what came before', async () => {
const { effects, calls } = spyEffects({
applySchema: async () => {
throw new Error('relation already exists');
},
});
const out = await runInstall({ entry: photos, mode: 'provisioned', values: {}, effects });
expect(out.status).toBe('failed');
if (out.status !== 'failed') throw new Error('unreachable');
expect(out.at).toBe('schema');
expect(out.error).toBe('relation already exists');
// Resumable: the three that worked are recorded, so a retry does not redo them.
expect(out.completed).toEqual(['preflight', 'provision', 'connect']);
expect(calls).not.toContain('process');
});
it('treats a failed preflight as a failure before anything is written', async () => {
const { effects, calls } = spyEffects({
preflight: async () => {
calls.push('preflight');
return { ok: false, reason: 'Docker is not installed.', remedy: 'Install it.' };
},
});
const out = await runInstall({ entry: photos, mode: 'provisioned', values: {}, effects });
expect(out.status).toBe('failed');
if (out.status !== 'failed') throw new Error('unreachable');
expect(out.at).toBe('preflight');
// The remedy travels with the reason: "Docker is not installed" without "install it" is a dead end.
expect(out.error).toContain('Install it.');
// Nothing provisioned, so there is nothing to unwind — the entire reason preflight goes first.
expect(calls).toEqual(['preflight']);
});
it('turns a thrown effect into a recorded failure rather than an escape', async () => {
// An effect that throws is a bug in that effect. If it escaped, the row would be stranded in
// `installing` with nothing to resume from.
const { effects } = spyEffects({
startProcess: async () => {
throw new Error('pm2 not found');
},
});
const out = await runInstall({ entry: photos, mode: 'provisioned', values: {}, effects });
expect(out.status).toBe('failed');
if (out.status !== 'failed') throw new Error('unreachable');
expect(out.at).toBe('process');
});
});
+190
View File
@@ -0,0 +1,190 @@
import type { CatalogueEntry, InstallMode } from './catalogue';
import type { Preflight } from './preflight';
// Installing one sidecar, as a sequence of named steps that can stop anywhere and be resumed.
//
// ── Why a step machine and not a function ──
//
// Install spans a container start, a health wait, an upstream API call and a process start. Any of them
// can fail, and one of them (a token only a human can mint) is EXPECTED to stop the run. A straight-line
// function has two bad options at that point: unwind everything, or leave the user with a half-installed
// service that neither works nor uninstalls. The second is the one people cannot get out of.
//
// So each step is named, its completion is persisted in `sidecar_installs.completed_steps`, and running
// install again resumes from where it stopped. Re-running a completed step is never necessary, but is
// also never harmful — every effect below is required to be idempotent, because the alternative is
// trusting that a crash never lands between "did the thing" and "recorded the thing".
//
// ── Why the effects are injected ──
//
// `planSteps` is pure and `runInstall` takes its side effects as an argument, so the whole machine —
// ordering, resume, blocking, failure — is testable without Docker, Postgres, PM2 or an Immich to talk
// to. The parts that genuinely touch the world stay thin enough to read.
export type StepName =
/** Host is capable of this: Docker present for a provisioned install, display present for vnc. */
| 'preflight'
/** Render the compose template and bring the containers up. Provisioned installs only. */
| 'provision'
/** Write the owner's `service_connections` row — the URL and credential this install is reachable by. */
| 'connect'
/** Apply the sidecar's own schema. */
| 'schema'
/** Copy the sidecar's icon and assets to where the browser can fetch them. */
| 'assets'
/** Start the PM2 process. */
| 'process'
/** Give every existing member their own account, where the service supports it. */
| 'members';
export type StepResult =
| { status: 'done'; results?: Record<string, string> }
/**
* Everything up to here worked and the run cannot continue without a human.
*
* A real state, not a failure: for Immich, Jellyfin and Memos the container is up and healthy and we
* are waiting for a token only their own UI can mint. Reporting this as an error would make a normal
* install look broken and invite the user to tear down a container that is working perfectly.
*/
| { status: 'blocked'; reason: string; completeAt?: string }
| { status: 'failed'; error: string };
/** What a step is given. Deliberately small — a step that needs more probably belongs in the sidecar. */
export type StepContext = {
entry: CatalogueEntry;
mode: InstallMode;
/** Answers from the install form, plus anything earlier steps returned via `OFFICER_RESULT_*`. */
values: Record<string, string>;
/** Progress for the log the UI streams into a terminal panel. */
log: (line: string) => void;
};
/**
* The world, as the installer touches it. Every one of these MUST be idempotent see above.
*/
export type InstallEffects = {
preflight(entry: CatalogueEntry, mode: InstallMode): Promise<Preflight>;
/** Run the sidecar's setup.sh. Returns whatever it printed as `OFFICER_RESULT_<KEY>=value`. */
provision(ctx: StepContext): Promise<{ results: Record<string, string>; composeDir: string }>;
/** Write the owner's connection row. `blocked` when the service can only be connected by a human. */
connect(ctx: StepContext): Promise<StepResult>;
applySchema(ctx: StepContext): Promise<void>;
publishAssets(ctx: StepContext): Promise<void>;
startProcess(ctx: StepContext): Promise<void>;
provisionMembers(ctx: StepContext): Promise<void>;
};
/**
* Which steps this install needs, in order a pure function of the entry and the chosen mode.
*
* Separated from running them so the UI can show what is about to happen, and so ordering is testable
* on its own. The two rules that matter:
*
* - `provision` exists only for `provisioned`. Pointing at an instance the user already runs must
* never start a container, and this is where that is guaranteed rather than remembered.
* - `members` is omitted entirely when the service has no user concept, so a Transmission install does
* not report a members step that did nothing.
*/
export function planSteps(entry: CatalogueEntry, mode: InstallMode): StepName[] {
const steps: StepName[] = ['preflight'];
if (mode === 'provisioned') steps.push('provision');
// `config` installs have nothing to point at, so there is no connection row to write.
if (mode !== 'config') steps.push('connect');
// Assets before the process: the dock reads manifests as soon as the install is recorded, and an icon
// that arrives a moment later shows as broken on the first render.
steps.push('schema', 'assets', 'process');
if (entry.members !== 'none') steps.push('members');
return steps;
}
export type InstallOutcome =
| { status: 'installed'; completed: StepName[] }
| { status: 'blocked'; completed: StepName[]; at: StepName; reason: string; completeAt?: string }
| { status: 'failed'; completed: StepName[]; at: StepName; error: string };
export type RunInstallParams = {
entry: CatalogueEntry;
mode: InstallMode;
values: Record<string, string>;
/** Steps already done by an earlier attempt. Passing them is what makes this a resume. */
completed?: StepName[];
effects: InstallEffects;
log?: (line: string) => void;
};
/**
* Run (or resume) an install.
*
* Returns rather than throws, because every outcome here is something the caller has to record: a
* failure updates `last_error` and leaves the row resumable, and a block is a normal pause. Throwing
* would make the caller's job "catch and guess which of those happened".
*/
export async function runInstall(params: RunInstallParams): Promise<InstallOutcome> {
const { entry, mode, effects } = params;
const log = params.log ?? (() => {});
const plan = planSteps(entry, mode);
const completed = [...(params.completed ?? [])];
// Copied, not aliased: a resumed run must not mutate the caller's record of what an earlier attempt
// achieved, or a failure halfway through would silently rewrite history.
const values = { ...params.values };
for (const step of plan) {
if (completed.includes(step)) {
log(`· ${step} — already done, skipping`);
continue;
}
const ctx: StepContext = { entry, mode, values, log };
log(`${step}`);
try {
const result = await runStep(step, ctx, effects);
if (result.status === 'failed') {
return { status: 'failed', completed, at: step, error: result.error };
}
if (result.status === 'blocked') {
// Note that `completed` does NOT include this step: resuming re-runs it, which is the point —
// the human has now supplied what it was waiting for.
return { status: 'blocked', completed, at: step, reason: result.reason, completeAt: result.completeAt };
}
// Results feed forward: `provision` discovers the URL a `connect` two steps later writes down.
if (result.results) Object.assign(values, result.results);
completed.push(step);
} catch (err) {
// An effect that throws is a bug in that effect, not a different kind of failure. Recording it the
// same way keeps the row resumable instead of stranding it in `installing` forever.
return { status: 'failed', completed, at: step, error: err instanceof Error ? err.message : String(err) };
}
}
return { status: 'installed', completed };
}
async function runStep(step: StepName, ctx: StepContext, effects: InstallEffects): Promise<StepResult> {
switch (step) {
case 'preflight': {
const check = await effects.preflight(ctx.entry, ctx.mode);
return check.ok ? { status: 'done' } : { status: 'failed', error: `${check.reason} ${check.remedy}` };
}
case 'provision': {
const { results, composeDir } = await effects.provision(ctx);
return { status: 'done', results: { ...results, composeDir } };
}
case 'connect':
return effects.connect(ctx);
case 'schema':
await effects.applySchema(ctx);
return { status: 'done' };
case 'assets':
await effects.publishAssets(ctx);
return { status: 'done' };
case 'process':
await effects.startProcess(ctx);
return { status: 'done' };
case 'members':
await effects.provisionMembers(ctx);
return { status: 'done' };
}
}
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'bun:test';
import { plannedOutcome, servicesNeedingMemberProvision } from './members';
import { byId } from './catalogue';
describe('what adding a member would do', () => {
it('predicts the outcome per service without contacting anything', () => {
expect(plannedOutcome(byId('photos')!)).toBe('provisioned');
expect(plannedOutcome(byId('vault')!)).toBe('invited');
expect(plannedOutcome(byId('transmission')!)).toBe('not-applicable');
});
});
describe('which installed services have work to do', () => {
it('skips single-tenant daemons entirely', () => {
const ids = servicesNeedingMemberProvision(['photos', 'transmission', 'slskd', 'vault']).map((e) => e.id);
expect(ids.sort()).toEqual(['photos', 'vault']);
});
it('ignores services that are not installed', () => {
// The catalogue is what COULD be installed; only what IS installed has a member to provision.
expect(servicesNeedingMemberProvision([])).toEqual([]);
expect(servicesNeedingMemberProvision(['jellyfin']).map((e) => e.id)).toEqual(['jellyfin']);
});
});
+99
View File
@@ -0,0 +1,99 @@
import type { CatalogueEntry } from './catalogue';
import { CATALOGUE } from './catalogue';
// Giving every member their own account on the services the owner installed.
//
// ── Why this is not part of install ──
//
// It has two triggers, and only ever handling the first is how this rots:
//
// install a service → provision every member who already exists
// add a member → provision every service already installed
//
// A member added next month to an Immich installed today needs exactly the same work to happen, and
// nobody will remember to do it by hand. So the unit is (service × member), reachable from both sides,
// rather than a loop inside the installer.
//
// ── What "provisioned" means, and where it is recorded ──
//
// There is no new table. A member is provisioned for a service exactly when they have a
// `service_connections` row for it — which already carries their own credential and a NULL `url`,
// inheriting the instance from the owner's row. That schema was built for this before this existed, and
// adding a second record of the same fact would only create the chance for the two to disagree.
//
// ── The ceiling ──
//
// `members: 'invite'` is not a weaker version of `'accounts'`; it is a different outcome. Vaultwarden
// derives its encryption key from the master password, so a credential we could mint would mean a vault
// we could read. The account is created and the member sets their own password. Transparent right up to
// the point where being transparent would be a defect.
export type MemberProvisionOutcome =
/** Account exists and a credential was written. The member finds the feature working. */
| { status: 'provisioned' }
/** Account exists; the member must complete it themselves. Carries where to send them. */
| { status: 'invited'; completeAt: string }
/** Nothing to do — a single-tenant daemon. Not a failure. */
| { status: 'not-applicable' }
/** Upstream refused. The caller records it; the member simply has no access yet. */
| { status: 'failed'; error: string };
/**
* What provisioning this member for this service would do without doing it.
*
* Split out so the UI can say "adding Ana will give her Photos and Jellyfin, and invite her to the
* vault" BEFORE anyone commits, and so the decision is testable without an Immich to talk to.
*/
export function plannedOutcome(entry: CatalogueEntry): MemberProvisionOutcome['status'] {
switch (entry.members) {
case 'accounts':
return 'provisioned';
case 'invite':
return 'invited';
case 'none':
return 'not-applicable';
}
}
/**
* Every installed service that has anything to do for a new member.
*
* `'none'` entries are filtered out here rather than inside the loop that provisions, so the caller can
* distinguish "nothing to do" from "did nothing" the two look identical at a call site and mean very
* different things when someone is debugging why a member cannot see a feature.
*/
export function servicesNeedingMemberProvision(installedIds: readonly string[]): CatalogueEntry[] {
const installed = new Set(installedIds);
return CATALOGUE.filter((e) => installed.has(e.id) && e.members !== 'none');
}
/**
* The per-service work, to be implemented alongside each sidecar rather than centrally.
*
* Deliberately an interface and not a switch: the whole point of the app store is that a sidecar carries
* everything it needs, and a central function that grows a case per service is the thing that stops any
* of this shipping from its own repository. A third-party plugin implements this; nothing in core knows
* its name.
*
* Implementations MUST be idempotent. Both triggers can fire for the same pair a member added while an
* install is still running is not a rare race, it is a Tuesday and creating a second account upstream
* is not recoverable from our side. Treat "already exists" as success.
*/
export type MemberProvisioner = {
sidecarId: string;
/**
* Create (or confirm) the member's account upstream and return their credential.
*
* Returning `invited` is a normal outcome, not an error path: it means the account is real and the
* member must finish it. `completeAt` is where to send them.
*/
provision(params: { userId: number; email: string; username: string }): Promise<MemberProvisionOutcome>;
/**
* Undone when a member is removed, or when the service is uninstalled with data disposal.
*
* Separate from provision because the honest default is to leave the upstream account alone: deleting
* a user in Immich deletes their photos, and an app store that silently destroys data on an unrelated
* action is worse than one that leaves a stale account behind.
*/
deprovision?(params: { userId: number }): Promise<void>;
};
+60
View File
@@ -0,0 +1,60 @@
import { dirname, join } from 'node:path';
import { DATA_PATH } from '../data-path';
// Where the app store puts the containers it provisions.
//
// ── The install layout ──
//
// A machine that runs Officer is meant to look like this, whether the owner is seasoned or not:
//
// ~/officerdev/
// platform/ the app
// data/ DATA_PATH — managed homes, attachments, job logs
// dockers/ services the app store provisioned <- this file
// capabilities/ the file-based item store
//
// One root, everything under it, nothing scattered. `OFFICER_ROOT` is derived from `DATA_PATH` rather
// than configured separately, because a second environment variable that must agree with the first is a
// second thing to get wrong — and on a correct install `data/` is always a direct child of the root.
//
// (This development machine predates the convention and has it inverted: the whole project sits inside
// `~/dockers/officer.dev/`, so the root derives to `officer.dev` and the app store's directory would be
// `~/dockers/officer.dev/dockers`. Which is ugly, and correct — it is still isolated, still under one
// root, and still not mixed in with anything else. New installs get the clean shape.)
//
// ── Why this is not `~/dockers` ──
//
// That is where a seasoned user already keeps their own estate — 47 services on this machine alone. Two
// reasons to stay out of it:
//
// 1. **Isolation.** Containers the app store created and containers the user manages must be
// distinguishable without inspecting them. A separate root makes that structural rather than a
// naming convention we would have to enforce and they could break.
// 2. **We never reason about someone else's compose files.** The app store does not scan, adopt or
// modify anything outside its own directory. "I already have one of these" is answered by the user
// giving a URL (`mode: 'existing'`), never by us finding a directory and guessing it is theirs.
//
// So this directory is exclusively ours to write, and everything in it was put there by an install.
/**
* The install root the parent of `data/`. On a conventional install, `~/officerdev`.
*
* Derived, not configured: see above.
*/
export const OFFICER_ROOT = dirname(DATA_PATH);
/** Where provisioned services live, one directory each. Created on first install, not at boot. */
export const DOCKERS_DIR = join(OFFICER_ROOT, 'dockers');
/**
* This service's own directory: `<root>/dockers/<id>/`, holding `docker-compose.yaml` and because the
* templates use relative bind mounts rather than named volumes its data and configuration too.
*
* That is the convention the owner already uses everywhere: `./data`, `./database`, `./storage` beside
* the compose file, so both the app and a human can see exactly what a service is keeping and where.
* A named volume hides it behind `docker volume inspect`, which is the opposite of the point.
*/
export const serviceDir = (sidecarId: string): string => join(DOCKERS_DIR, sidecarId);
/** The compose file the installer renders and `docker compose` is run against. */
export const composeFile = (sidecarId: string): string => join(serviceDir(sidecarId), 'docker-compose.yaml');
+98
View File
@@ -0,0 +1,98 @@
// Starting and stopping a sidecar's PM2 process.
//
// ── Why shell out rather than use the pm2 API ──
//
// PM2 is already the supervisor for every process here and the ecosystem file is already the definition
// of how each one runs. Importing pm2 as a library would put a second thing in charge of that, and the
// failure mode is two supervisors disagreeing about what should be running. The CLI is the same
// interface a human uses, which also means an owner can undo anything the app store did with a command
// they already know.
//
// ── The one PM2 fact that matters here ──
//
// `pm2 start <name>` only works for a process PM2 has already seen. A sidecar that has never run is not
// in PM2's list, and starting it by name fails with "process or namespace not found". So a first
// install has to start it FROM THE ECOSYSTEM FILE, and every start after that can go by name.
//
// `startProcess` handles both without the caller having to know which case it is in, because the caller
// genuinely cannot know: a resumed install, a re-enable after a restart, and a first install all arrive
// at the same line.
export type Pm2Result = { ok: true } | { ok: false; error: string };
const ECOSYSTEM = 'ecosystem.config.cjs';
async function pm2(args: string[], cwd: string): Promise<{ code: number; out: string }> {
const proc = Bun.spawn(['pm2', ...args], { cwd, stdout: 'pipe', stderr: 'pipe' });
const [out, err] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
const code = await proc.exited;
return { code, out: `${out}${err}`.trim() };
}
/** Is this process known to PM2 at all — running, stopped or errored? */
export async function isKnownToPm2(name: string, cwd: string): Promise<boolean> {
const { code, out } = await pm2(['jlist'], cwd);
if (code !== 0) return false;
try {
const list = JSON.parse(out) as Array<{ name?: string }>;
return list.some((p) => p.name === name);
} catch {
// jlist printing something unparseable means PM2 is in a state we should not guess about. Saying
// "not known" makes the caller start from the ecosystem file, which is the safe direction — it
// works whether or not the process exists.
return false;
}
}
/**
* Start a sidecar, whether or not PM2 has seen it before.
*
* `--only` is what keeps this surgical: starting the ecosystem file without it would bring up every
* process in the estate, which on a light install is precisely the thing the user opted out of.
*/
export async function startProcess(name: string, cwd: string): Promise<Pm2Result> {
const known = await isKnownToPm2(name, cwd);
const args = known ? ['start', name] : ['start', ECOSYSTEM, '--only', name];
const { code, out } = await pm2(args, cwd);
if (code !== 0) return { ok: false, error: out || `pm2 ${args.join(' ')} exited ${code}` };
return { ok: true };
}
/**
* Stop it, leaving it in PM2's list.
*
* Stop rather than delete, deliberately. A stopped process still appears in `pm2 list` as stopped,
* which is the honest picture for a disabled sidecar deleting it would make a disabled service
* indistinguishable from one that was never installed, both to PM2 and to anyone looking.
*/
export async function stopProcess(name: string, cwd: string): Promise<Pm2Result> {
const { code, out } = await pm2(['stop', name], cwd);
// Stopping something that is not there is the desired end state, not an error. This happens on a
// resumed uninstall, and treating it as a failure would leave the row un-uninstallable.
if (code !== 0 && !/not found|doesn't exist/i.test(out)) {
return { ok: false, error: out || `pm2 stop ${name} exited ${code}` };
}
return { ok: true };
}
/** Remove it from PM2 entirely. Uninstall only — see `stopProcess` for why disable does not do this. */
export async function deleteProcess(name: string, cwd: string): Promise<Pm2Result> {
const { code, out } = await pm2(['delete', name], cwd);
if (code !== 0 && !/not found|doesn't exist/i.test(out)) {
return { ok: false, error: out || `pm2 delete ${name} exited ${code}` };
}
return { ok: true };
}
/** 'online' | 'stopped' | 'errored' | … , or null when PM2 has never heard of it. */
export async function processStatus(name: string, cwd: string): Promise<string | null> {
const { code, out } = await pm2(['jlist'], cwd);
if (code !== 0) return null;
try {
const list = JSON.parse(out) as Array<{ name?: string; pm2_env?: { status?: string } }>;
return list.find((p) => p.name === name)?.pm2_env?.status ?? null;
} catch {
return null;
}
}
+22
View File
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'bun:test';
import { needsDocker, preflight } from './preflight';
import { CATALOGUE, byId } from './catalogue';
describe('needsDocker is derived, not declared', () => {
it('is true for exactly the entries that can provision', () => {
for (const e of CATALOGUE) expect(needsDocker(e)).toBe(e.modes.includes('provisioned'));
});
});
describe('preflight is per mode, not per entry', () => {
it('allows pointing at an existing instance without Docker', async () => {
// The whole point: a host with no Docker can still use Photos against an Immich elsewhere.
// Refusing the entry outright is the over-strict check that makes people bypass the installer.
const photos = byId('photos')!;
expect(await preflight(photos, 'existing')).toEqual({ ok: true });
});
it('allows a config-only sidecar regardless', async () => {
expect(await preflight(byId('email')!, 'config')).toEqual({ ok: true });
});
});
+84
View File
@@ -0,0 +1,84 @@
import type { CatalogueEntry } from './catalogue';
// Can this machine install this sidecar at all — asked BEFORE anything is written.
//
// The point is the ordering. An install that discovers a missing dependency halfway through has already
// created a directory, possibly started a container, and written a row; it then has to unwind, and the
// user is left with something that neither works nor uninstalls. A check that costs 30ms up front is
// worth a great deal of that.
//
// ── What this deliberately does NOT do ──
//
// It does not install anything. Today nothing in `scripts/` installs Docker either — `setup.sh` runs
// `setup-dockers.sh`, which invokes `docker compose` without ever checking it exists, so a fresh host
// without Docker fails partway through setup with a bare "command not found". That is a real gap, and
// the intended fix is a per-sidecar `setup.sh` that ensures its own dependencies — which is also the
// shape a sidecar needs once it lives in its own repository and ships independently.
//
// Until that exists, the honest thing is to detect and report rather than to guess or to half-install.
export type Preflight =
| { ok: true }
| { ok: false; reason: string; /** What the user has to do about it. */ remedy: string };
/**
* Docker is needed to PROVISION, never to point at something already running. Derived from `modes`
* rather than declared per entry, so the two cannot drift: an entry that can provision needs Docker, by
* definition, and nobody has to remember to tick a second box.
*/
export const needsDocker = (entry: CatalogueEntry): boolean => entry.modes.includes('provisioned');
/** `docker` on PATH, the daemon reachable, and the compose plugin present. All three, or it is not usable. */
export async function checkDocker(): Promise<Preflight> {
try {
// `docker compose version` exercises the binary, the daemon connection and the plugin in one call.
// `docker --version` would pass with a dead daemon, which is the failure people actually hit.
const proc = Bun.spawn(['docker', 'compose', 'version'], { stdout: 'pipe', stderr: 'pipe' });
const code = await proc.exited;
if (code === 0) return { ok: true };
const err = (await new Response(proc.stderr).text()).trim();
// The daemon being down and the plugin being absent need different remedies, and the message is the
// only way to tell them apart — the exit code is 1 for both.
if (/permission denied|daemon|cannot connect/i.test(err)) {
return {
ok: false,
reason: 'The Docker daemon is not reachable.',
remedy:
'Start Docker (`sudo systemctl start docker`), or add your user to the `docker` group and log in again.',
};
}
return {
ok: false,
reason: 'Docker Compose is not available.',
remedy: 'Install the Docker Compose plugin (`docker-compose-plugin`).',
};
} catch {
return {
ok: false,
reason: 'Docker is not installed.',
remedy: 'Install Docker Engine, then try again. https://docs.docker.com/engine/install/',
};
}
}
/**
* Everything that must be true before `mode` can be attempted for `entry`.
*
* Split by mode on purpose: a host with no Docker can still install Photos by pointing at an Immich
* somewhere else. Refusing the whole entry would be wrong, and is the sort of over-strict check that
* makes people work around the installer instead of using it.
*/
export async function preflight(entry: CatalogueEntry, mode: string): Promise<Preflight> {
if (entry.requires === 'linux-display' && process.platform !== 'linux') {
return {
ok: false,
reason: `${entry.label} mirrors an Xorg display, which this platform does not have.`,
remedy: 'This sidecar only runs on a Linux host with a display.',
};
}
if (mode === 'provisioned' && needsDocker(entry)) return checkDocker();
return { ok: true };
}
+57
View File
@@ -0,0 +1,57 @@
import { describe, expect, it } from 'bun:test';
import { parseResults } from './run-script';
// The line protocol between a setup script and the installer. Pure, so it is tested without spawning
// anything — and worth testing precisely, because a script's output is interleaved with `docker
// compose`'s and a loose parser would pick up things that merely look like results.
describe('parseResults', () => {
it('reads the values a script hands back', () => {
const out = parseResults(
[
'==> Starting',
'OFFICER_RESULT_URL=http://127.0.0.1:18091',
'OFFICER_RESULT_PATH=/transmission/rpc',
'==> Done',
].join('\n'),
);
expect(out).toEqual({ url: 'http://127.0.0.1:18091', path: '/transmission/rpc' });
});
it('ignores everything that is not a result line', () => {
// The same stream is the user's live log. Narration must never be mistaken for a value.
const out = parseResults(
['Container officer-transmission Started', 'note: OFFICER_RESULT_URL is printed at the end', ''].join('\n'),
);
// The middle line MENTIONS the prefix but does not start with it, which is the realistic near-miss.
expect(out).toEqual({});
});
it('keeps an empty value, because blank is a real answer', () => {
// Transmission with no RPC auth returns exactly this, and it means "no username", which is
// different from the key being absent.
expect(parseResults('OFFICER_RESULT_USERNAME=')).toEqual({ username: '' });
});
it('keeps everything after the first `=`', () => {
// Tokens and URLs contain `=`; splitting on every one would silently truncate a credential.
expect(parseResults('OFFICER_RESULT_SECRET=abc=def==')).toEqual({ secret: 'abc=def==' });
});
it('lets a later line win', () => {
// A script that reports twice has changed its mind — a retry inside it that finally worked.
expect(parseResults(['OFFICER_RESULT_URL=http://first', 'OFFICER_RESULT_URL=http://second'].join('\n'))).toEqual({
url: 'http://second',
});
});
it('skips a prefix with no assignment rather than storing a blank key', () => {
// A script bug. Storing `{'': ''}` would look like a deliberate empty value downstream.
expect(parseResults('OFFICER_RESULT_')).toEqual({});
expect(parseResults('OFFICER_RESULT_=novalue')).toEqual({});
});
it('tolerates indentation, since stderr lines arrive prefixed', () => {
expect(parseResults(' OFFICER_RESULT_URL=http://x ')).toEqual({ url: 'http://x' });
});
});
+132
View File
@@ -0,0 +1,132 @@
import { join } from 'node:path';
import { serviceDir } from './paths';
// Running a sidecar's setup.sh, streaming what it prints, and collecting what it hands back.
//
// The script contract lives in `templates/README.md`. Two halves of it are implemented here: answers go
// in as environment (never as prompts, which behind a web form is a hang with no output), and results
// come back as `OFFICER_RESULT_<KEY>=value` lines on stdout.
//
// ── Why results are a line protocol and not JSON on stdout ──
//
// The same stream is the user's live log — it goes to a terminal panel while the install runs. A script
// that must emit clean JSON cannot also narrate, and one that emits both needs a framing convention
// anyway. A prefixed line is that convention, it survives being interleaved with `docker compose`
// output, and a human reading the log can see exactly what was handed back.
//
// This mirrors the progress sentinel the job runner already uses (`@@officer:progress@@`), for the same
// reason and with the same rule: the marker lines are plucked out of the stream, and everything else is
// passed through as narration.
const RESULT_PREFIX = 'OFFICER_RESULT_';
/**
* Pull `OFFICER_RESULT_*` assignments out of a script's output.
*
* Pure, so the protocol is testable without spawning anything.
*
* Later lines win. A script that reports a value twice has changed its mind a retry inside the script
* that finally succeeded, say and the last word is the one that reflects reality.
*/
export function parseResults(output: string): Record<string, string> {
const results: Record<string, string> = {};
for (const raw of output.split('\n')) {
const line = raw.trim();
if (!line.startsWith(RESULT_PREFIX)) continue;
const eq = line.indexOf('=');
// A prefix with no `=` is a script bug, not a value. Skipping beats storing a key with an empty
// string, which would look like a deliberate blank later.
if (eq <= RESULT_PREFIX.length) continue;
const key = line.slice(RESULT_PREFIX.length, eq).toLowerCase();
results[key] = line.slice(eq + 1);
}
return results;
}
export type RunScriptParams = {
sidecarId: string;
/** Directory holding the template's `setup.sh`, i.e. `app-store/templates/<template>`. */
templateDir: string;
/** Answers from the install form. Keys are used verbatim as environment variable names. */
env: Record<string, string>;
/** Called per line, as it happens — this is what the terminal panel renders. */
log: (line: string) => void;
/** Guard against a script that hangs rather than fails. */
timeoutMs?: number;
};
export type RunScriptOutcome =
| { ok: true; results: Record<string, string>; serviceDir: string }
| { ok: false; error: string };
/** Ten minutes: long enough to pull a large image on a slow line, short enough to not hang a UI forever. */
const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000;
export async function runSetupScript(params: RunScriptParams): Promise<RunScriptOutcome> {
const dir = serviceDir(params.sidecarId);
const script = join(params.templateDir, 'setup.sh');
const proc = Bun.spawn(['bash', script], {
// The script's own working directory is its template dir; where it WRITES is OFFICER_SERVICE_DIR.
// Keeping those separate is what stops a script from accidentally writing into the repo.
cwd: params.templateDir,
env: {
...process.env,
...params.env,
OFFICER_SERVICE_DIR: dir,
// A form cannot answer a prompt, so a script that would block must fail loudly instead. This is
// the flag that turns a hang into a diagnosable error.
OFFICER_NONINTERACTIVE: '1',
OFFICER_UID: String(process.getuid?.() ?? 1000),
OFFICER_GID: String(process.getgid?.() ?? 1000),
},
stdout: 'pipe',
stderr: 'pipe',
});
const timeout = setTimeout(() => {
params.log(`✗ timed out after ${(params.timeoutMs ?? DEFAULT_TIMEOUT_MS) / 1000}s — killing`);
proc.kill();
}, params.timeoutMs ?? DEFAULT_TIMEOUT_MS);
// Line-buffered so the panel updates as things happen rather than in one dump at the end — the whole
// reason for streaming. Both streams are forwarded: `docker compose` writes its progress to stderr,
// so dropping it would hide most of what a user wants to watch.
const collected: string[] = [];
const pump = async (stream: ReadableStream<Uint8Array>, prefix = '') => {
const decoder = new TextDecoder();
let buffer = '';
for await (const chunk of stream as unknown as AsyncIterable<Uint8Array>) {
buffer += decoder.decode(chunk, { stream: true });
let nl: number;
while ((nl = buffer.indexOf('\n')) >= 0) {
const line = buffer.slice(0, nl);
buffer = buffer.slice(nl + 1);
collected.push(line);
params.log(prefix + line);
}
}
if (buffer) {
collected.push(buffer);
params.log(prefix + buffer);
}
};
await Promise.all([pump(proc.stdout), pump(proc.stderr, ' ')]);
const code = await proc.exited;
clearTimeout(timeout);
if (code !== 0) {
// The last few lines are almost always the reason; the whole log is already in the panel above.
//
// `collected` can interleave differently from real time: the two streams are pumped concurrently and
// append as they arrive, so a stderr line can land before a stdout line that was printed first. The
// LIVE log is correctly ordered — each `log()` fires as its line arrives — and only this summary can
// read out of order. Kept concurrent deliberately: serialising the pumps would make a script that
// writes a lot to one stream block on the other.
const tail = collected.slice(-5).join('\n').trim();
return { ok: false, error: `setup.sh exited ${code}${tail ? `: ${tail}` : ''}` };
}
return { ok: true, results: parseResults(collected.join('\n')), serviceDir: dir };
}
+198
View File
@@ -0,0 +1,198 @@
import {
listSidecarInstalls,
getSidecarInstall,
beginInstall,
markInstalled,
markFailed,
markBlocked,
recordSteps,
setEnabled as setEnabledRow,
removeInstall,
type SidecarInstall,
} from 'officerdb';
import { CATALOGUE, byId, type CatalogueEntry, type InstallMode } from './catalogue';
import { runInstall, type StepName } from './installer';
import { createEffects } from './effects';
import { startProcess, stopProcess, deleteProcess, processStatus } from './pm2';
import { plannedOutcome } from './members';
import { composeUp, composeStop, composeDown } from './compose';
import { unpublishAssets } from './assets';
import { serviceDir } from './paths';
// The app store's operations, between the HTTP routes and the machinery. Routes stay about HTTP; this
// stays about what installing, enabling and uninstalling actually mean.
const PLATFORM_DIR = process.cwd();
export type StoreItem = {
id: string;
label: string;
summary: string;
modes: InstallMode[];
members: CatalogueEntry['members'];
/** What adding a member would do for this service, so the UI can say so before anyone commits. */
memberOutcome: ReturnType<typeof plannedOutcome>;
existingFields: CatalogueEntry['existingFields'];
configFields: CatalogueEntry['configFields'];
install: {
status: 'not-installed' | SidecarInstall['status'];
enabled: boolean;
mode: string | null;
lastError: string | null;
completedSteps: string[];
};
/** PM2's own view. Included because the row saying `enabled` and the process being dead is the
* interesting case, and hiding it would make the store lie about what is running. */
processStatus: string | null;
};
/** The catalogue joined to what has actually happened to each entry. */
export async function listStore(): Promise<StoreItem[]> {
const installs = new Map((await listSidecarInstalls()).map((row) => [row.sidecarId, row]));
return Promise.all(
CATALOGUE.map(async (entry) => {
const row = installs.get(entry.id);
return {
id: entry.id,
label: entry.label,
summary: entry.summary,
modes: entry.modes,
members: entry.members,
memberOutcome: plannedOutcome(entry),
existingFields: entry.existingFields,
configFields: entry.configFields,
install: {
status: row?.status ?? 'not-installed',
enabled: row?.enabled ?? false,
mode: row?.mode ?? null,
lastError: row?.lastError ?? null,
completedSteps: (row?.completedSteps as string[]) ?? [],
},
// Only asked for things that claim to be installed: `pm2 jlist` per catalogue entry would be
// fourteen subprocesses to render a page.
processStatus: row ? await processStatus(entry.process, PLATFORM_DIR) : null,
};
}),
);
}
export type InstallRequest = {
sidecarId: string;
mode: InstallMode;
/** Answers from the form. Passed to the setup script as environment and to `connect` as values. */
values: Record<string, string>;
log?: (line: string) => void;
};
/**
* Install, or resume an install that stopped.
*
* Resume is not a separate entry point on purpose: pressing the button again after a failure, and
* pressing it again after supplying the API key it was waiting for, are the same action from the user's
* side. The recorded steps decide what actually re-runs.
*/
export async function install(req: InstallRequest) {
const entry = byId(req.sidecarId);
if (!entry) throw new Error(`unknown sidecar: ${req.sidecarId}`);
if (!entry.modes.includes(req.mode)) {
throw new Error(`${entry.label} cannot be installed as '${req.mode}'`);
}
const row = await beginInstall(entry.id, req.mode);
const completed = (row.completedSteps as StepName[]) ?? [];
const outcome = await runInstall({
entry,
mode: req.mode,
values: req.values,
completed,
effects: createEffects(),
log: req.log,
});
if (outcome.status === 'installed') {
// Recorded from the install rather than derived later: the directory is the user's and they may move
// it, and uninstall must not guess at a path it is about to run `docker compose down` in.
if (req.mode === 'provisioned') await recordSteps(entry.id, outcome.completed, serviceDir(entry.id));
await markInstalled(entry.id, outcome.completed);
} else if (outcome.status === 'blocked') {
await markBlocked(entry.id, outcome.completed, outcome.reason);
} else {
await markFailed(entry.id, outcome.completed, outcome.error);
}
return outcome;
}
/**
* Start or stop a sidecar AND its container, without changing what is installed.
*
* Order matters in both directions, and it is the opposite each way:
*
* enable container first, then the process a sidecar that starts before its upstream exists
* spends its first seconds failing health checks and logging errors about a service that
* is merely not up yet.
* disable process first, then the container stopping the container underneath a running sidecar
* produces the same noise for the same reason, in reverse.
*
* `mode: 'existing'` has no container of ours, and `composeUp`/`composeStop` return `ran: false`
* rather than failing: there is no compose file, which is the correct state, not an error.
*/
export async function setEnabled(sidecarId: string, enabled: boolean) {
const entry = byId(sidecarId);
if (!entry) throw new Error(`unknown sidecar: ${sidecarId}`);
const row = await getSidecarInstall(sidecarId);
if (!row) throw new Error(`${entry.label} is not installed`);
const dir = row.composeDir ?? serviceDir(sidecarId);
if (enabled) {
const container = await composeUp(dir);
if (!container.ok) throw new Error(`could not start ${entry.label}'s container: ${container.error}`);
const process = await startProcess(entry.process, PLATFORM_DIR);
if (!process.ok) throw new Error(process.error);
} else {
const process = await stopProcess(entry.process, PLATFORM_DIR);
if (!process.ok) throw new Error(process.error);
const container = await composeStop(dir);
if (!container.ok) throw new Error(`could not stop ${entry.label}'s container: ${container.error}`);
}
await setEnabledRow(sidecarId, enabled);
return { ok: true as const };
}
/**
* Stop running this. Never "delete my data".
*
* What goes: the process (stopped, then removed from PM2), the containers (`docker compose down`, no
* `-v`), the published assets, and the install row.
*
* What stays: the service directory and everything under it configuration, databases, media and the
* sidecar's tables. Reinstalling later is therefore a restore rather than a fresh start.
*
* The assets are the only thing here that is deleted outright, and only because they are COPIES; the
* originals live with the sidecar. Nothing a user made is in that directory.
*/
export async function uninstall(sidecarId: string) {
const entry = byId(sidecarId);
if (!entry) throw new Error(`unknown sidecar: ${sidecarId}`);
const row = await getSidecarInstall(sidecarId);
const stopped = await stopProcess(entry.process, PLATFORM_DIR);
if (!stopped.ok) throw new Error(stopped.error);
await deleteProcess(entry.process, PLATFORM_DIR);
// Only for something we provisioned. An `existing` install points at a service the user runs
// themselves, and bringing it down would stop a container Officer did not start.
if (row?.mode === 'provisioned') {
const dir = row.composeDir ?? serviceDir(sidecarId);
const container = await composeDown(dir);
if (!container.ok) throw new Error(`could not remove ${entry.label}'s containers: ${container.error}`);
}
await unpublishAssets(sidecarId);
await removeInstall(sidecarId);
return { ok: true as const };
}
+82
View File
@@ -0,0 +1,82 @@
# Compose templates and their setup scripts
One directory per provisionable service. Each holds a `docker-compose.yaml` and a `setup.sh`, and
together they are everything needed to bring that service up.
This is deliberately the shape a sidecar will need when it lives in **its own repository**: metadata
(the catalogue entry), a compose template, a setup script, and a schema. Nothing here may assume it is
being read out of this repo.
---
## The setup.sh contract
**Answers come from the environment. It never prompts when they are already there.**
The app store collects them in a web form and passes them as environment variables. A person running it
by hand on a VPS gets prompted for anything missing, but only when stdin is a TTY — so the same script
serves both, and neither is a second code path.
```sh
OFFICER_SERVICE_DIR=~/officerdev/dockers/transmission \
OFFICER_UID=1000 OFFICER_GID=1000 \
TRANSMISSION_PORT=9091 \
bash setup.sh
```
Every script must:
| Rule | Why |
|---|---|
| **Be idempotent.** Running twice must be safe and must not create a second anything. | Install is resumable; a retry after a half-failure re-runs steps that already succeeded. |
| **Never prompt when `OFFICER_NONINTERACTIVE=1`.** Fail with a clear message instead. | A prompt behind a web form is a hang with no output, which is the worst failure to diagnose. |
| **Write only inside `OFFICER_SERVICE_DIR`.** | The app store owns that directory and nothing else. The user's own estate is never touched. |
| **Emit progress on stdout.** | The installer streams it to a terminal panel in the UI, so the user watches it happen rather than staring at a spinner. |
| **Print `OFFICER_RESULT_<KEY>=value` for anything the platform must store.** | How a generated secret or a resolved port gets back to `service_connections` without the installer parsing free text. |
Exit non-zero on failure, with the reason on stderr. The installer records it in `last_error` and the
row stays `failed` rather than pretending to be installed.
---
## Volumes are always relative bind mounts
`./data`, `./config`, `./database` — never named volumes. Configuration and data sit beside the compose
file so both the platform and a human can see exactly what a service keeps and where. A named volume
hides it behind `docker volume inspect`, which is the opposite of the point.
## Containers run as the owner
`user: "${OFFICER_UID}:${OFFICER_GID}"`, so files a container writes are owned by the person who
installed it and not by root. This is the convention already in use across the owner's own services.
## Ports bind to loopback unless the service genuinely needs to be reachable
`127.0.0.1:9091:9091`, not `9091:9091`. Officer reaches these over loopback; anything published on all
interfaces is a service exposed to the network by an installer the user trusted to be careful. The
exception is a service whose whole function is inbound connections — slskd's P2P listener, for example —
and those say so in a comment.
## No external networks
The owner's own composes attach to an external `nginx` network that exists on his machine. Templates
must not require one: a fresh VPS has no such network and `docker compose up` would fail before it
started. Default network only.
---
## Three provisioning shapes, not one
Worth knowing before writing a template, because the design doc originally assumed only the first:
1. **We set the credentials.** Passed as environment to the container, so the connection is fully known
the moment it is up. Transmission (`USER`/`PASS`), Vaultwarden (`ADMIN_TOKEN`).
2. **We generate a secret into a config file.** The bind mount lets us write it before first boot, so it
is still known without asking. slskd's API key lives in its `slskd.yml`.
3. **A human must mint a token in the service's own UI after it boots.** Immich, Jellyfin and Memos all
work this way — there is no environment variable that pre-seeds an API key.
Shape 3 means an install can be **provisioned and running, but not yet connected**. That is a real state,
not an error: the container is up, the compose file is written, and the platform is waiting for a token.
The installer stops there with the step recorded, and the UI asks for the key with a link to the page
that mints it. Resuming finishes the job.
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

@@ -0,0 +1,40 @@
# Transmission — the daemon Officer's transmission sidecar drives over RPC.
#
# Rendered by setup.sh; ${...} are substituted before this reaches disk. Everything the container keeps
# lives beside this file, so `ls` answers "what is this service storing" without docker involved.
name: officer-transmission
services:
transmission:
image: lscr.io/linuxserver/transmission:latest
container_name: officer-transmission
restart: unless-stopped
# As the installing user, so completed downloads are not root-owned — the single most annoying
# thing about a container that writes to a shared folder.
user: '${OFFICER_UID}:${OFFICER_GID}'
environment:
- PUID=${OFFICER_UID}
- PGID=${OFFICER_GID}
- TZ=${OFFICER_TZ}
# Blank means no RPC auth, which is Transmission's normal posture and is safe HERE specifically
# because the RPC port is bound to loopback below. setup.sh fills these only if the user asked for
# credentials; an empty USER is not the same as the variable being unset.
- USER=${TRANSMISSION_USER}
- PASS=${TRANSMISSION_PASS}
ports:
# Loopback only. Officer talks to this over 127.0.0.1; nothing else has any business reaching the
# RPC endpoint, and publishing it on all interfaces would put an unauthenticated control API on the
# network because "no auth" is the default above.
- '127.0.0.1:${TRANSMISSION_PORT}:9091'
# Peer traffic, and the exception to the loopback rule: BitTorrent peers must be able to connect
# inbound or the client is crippled to outbound-only connections.
- '${TRANSMISSION_PEER_PORT}:51413'
- '${TRANSMISSION_PEER_PORT}:51413/udp'
volumes:
- ./config:/config
- ./downloads:/downloads
- ./watch:/watch
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env bash
# Provision Transmission for Officer.
#
# Answers come from the environment; a human running this by hand is prompted for what is missing, but
# only when stdin is a TTY. See ../README.md for the contract every one of these obeys.
#
# OFFICER_SERVICE_DIR where to write. Everything this script creates is inside it.
# OFFICER_UID/GID who the container runs as
# TRANSMISSION_PORT RPC port on loopback (default 9091)
# TRANSMISSION_PEER_PORT BitTorrent listen port (default 51413)
# TRANSMISSION_USER/PASS optional RPC credentials (default: none, loopback-only)
#
# Idempotent: safe to re-run, which is what makes a resumed install work rather than duplicate.
set -euo pipefail
SERVICE_DIR="${OFFICER_SERVICE_DIR:?OFFICER_SERVICE_DIR is required}"
TEMPLATE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
OFFICER_UID="${OFFICER_UID:-$(id -u)}"
OFFICER_GID="${OFFICER_GID:-$(id -g)}"
OFFICER_TZ="${OFFICER_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}"
# ── Asking ────────────────────────────────────────────────────────────────────────────────────────────
# A prompt behind a web form is a hang with no output. When the caller says it cannot answer, fail with
# the reason instead of blocking forever on a read nobody will ever satisfy.
ask() {
local var="$1" prompt="$2" default="${3:-}"
local current="${!var:-}"
if [ -n "$current" ]; then return 0; fi
if [ "${OFFICER_NONINTERACTIVE:-0}" = "1" ] || [ ! -t 0 ]; then
if [ -n "$default" ]; then printf -v "$var" '%s' "$default"; return 0; fi
echo "error: $var is required and this is a non-interactive run" >&2
exit 2
fi
local answer
read -r -p "$prompt${default:+ [$default]}: " answer
printf -v "$var" '%s' "${answer:-$default}"
}
ask TRANSMISSION_PORT 'Transmission RPC port (loopback only)' '9091'
ask TRANSMISSION_PEER_PORT 'BitTorrent peer port' '51413'
TRANSMISSION_USER="${TRANSMISSION_USER:-}"
TRANSMISSION_PASS="${TRANSMISSION_PASS:-}"
# ── Render ────────────────────────────────────────────────────────────────────────────────────────────
echo "==> Preparing $SERVICE_DIR"
mkdir -p "$SERVICE_DIR/config" "$SERVICE_DIR/downloads" "$SERVICE_DIR/watch"
# envsubst with an explicit variable list, never the bare form: unrestricted envsubst would also expand
# anything in the template that merely looks like a variable, and a compose file is full of $ that
# belongs to other tools.
export OFFICER_UID OFFICER_GID OFFICER_TZ TRANSMISSION_PORT TRANSMISSION_PEER_PORT TRANSMISSION_USER TRANSMISSION_PASS
envsubst '${OFFICER_UID} ${OFFICER_GID} ${OFFICER_TZ} ${TRANSMISSION_PORT} ${TRANSMISSION_PEER_PORT} ${TRANSMISSION_USER} ${TRANSMISSION_PASS}' \
< "$TEMPLATE_DIR/docker-compose.yaml" > "$SERVICE_DIR/docker-compose.yaml"
echo "==> Starting the container"
# `up -d` is already idempotent: an unchanged compose file against a running container is a no-op, and a
# changed one recreates. That is the whole reason a re-run is safe.
docker compose --project-directory "$SERVICE_DIR" up -d
# ── Wait ──────────────────────────────────────────────────────────────────────────────────────────────
# Reporting success the moment `up -d` returns would be a lie: the container exists, the RPC endpoint is
# not listening yet, and the platform's first call would fail against a service we just said was ready.
echo "==> Waiting for the RPC endpoint"
for i in $(seq 1 60); do
# 409 is the correct healthy answer here — Transmission demands a session id and rejects the first
# request by design. Treating only 200 as healthy would wait out the full timeout on a working daemon.
code="$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:${TRANSMISSION_PORT}/transmission/rpc" || true)"
if [ "$code" = "409" ] || [ "$code" = "200" ] || [ "$code" = "401" ]; then
echo " up after ${i}s (HTTP $code)"
break
fi
if [ "$i" = "60" ]; then
echo "error: Transmission did not answer on 127.0.0.1:${TRANSMISSION_PORT} within 60s" >&2
echo " check: docker compose --project-directory '$SERVICE_DIR' logs" >&2
exit 1
fi
sleep 1
done
# ── Hand the connection back ──────────────────────────────────────────────────────────────────────────
# Shape 1 from the README: we set the credentials, so nothing has to be asked for after the fact. These
# lines are the installer's only interface to this script's results — parsed by prefix, never by
# scraping the log above.
echo "OFFICER_RESULT_URL=http://127.0.0.1:${TRANSMISSION_PORT}"
echo "OFFICER_RESULT_PATH=/transmission/rpc"
echo "OFFICER_RESULT_USERNAME=${TRANSMISSION_USER}"
echo "OFFICER_RESULT_SECRET=${TRANSMISSION_PASS}"
echo "==> Done"
@@ -0,0 +1,35 @@
# Vaultwarden — the Bitwarden-compatible server behind Officer's vault sidecar.
#
# Every request reaches it through us: app → /api/vault → officer-vault → here. Nothing else should be
# able to, which is why the port below is loopback-only.
name: officer-vault
services:
vaultwarden:
image: vaultwarden/server:latest
container_name: officer-vault
restart: unless-stopped
user: '${OFFICER_UID}:${OFFICER_GID}'
environment:
- TZ=${OFFICER_TZ}
# Argon2 hash of a token setup.sh generated. The plaintext is printed once, to the installer, and
# never written to disk here — a compose file is not a secret store, and this one sits in a
# directory the user is encouraged to read.
- ADMIN_TOKEN=${VAULTWARDEN_ADMIN_TOKEN_HASH}
# Closed by default. An open Vaultwarden on a machine the owner just handed a password manager to
# is the wrong default at every scale, and the owner can open it from the admin page if they want.
- SIGNUPS_ALLOWED=false
# WebSocket notifications, so the Bitwarden clients get live sync rather than polling.
- WEBSOCKET_ENABLED=true
ports:
# Loopback only, always. This is a password vault: the only thing that should reach it is the
# sidecar on this host, and publishing it on all interfaces would put it on the network.
- '127.0.0.1:${VAULTWARDEN_PORT}:80'
volumes:
# Holds the SQLite database, the attachments and the RSA keys. Bind-mounted rather than a named
# volume so the owner can see — and back up — exactly where their passwords live.
- ./data:/data
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env bash
# Provision Vaultwarden for Officer.
#
# Shape 2 from ../README.md: nothing is asked for, because the one credential that matters is GENERATED
# here. There is no sensible way for a user to invent an admin token, and asking for one produces a
# weaker secret than `openssl rand` does.
#
# OFFICER_SERVICE_DIR where to write
# OFFICER_UID/GID who the container runs as
# VAULTWARDEN_PORT loopback port (default 8222)
#
# Idempotent, including the token: an existing one is REUSED rather than rotated, because rotating on a
# re-run would lock the owner out of the admin page during a routine resumed install.
set -euo pipefail
SERVICE_DIR="${OFFICER_SERVICE_DIR:?OFFICER_SERVICE_DIR is required}"
TEMPLATE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
OFFICER_UID="${OFFICER_UID:-$(id -u)}"
OFFICER_GID="${OFFICER_GID:-$(id -g)}"
OFFICER_TZ="${OFFICER_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}"
VAULTWARDEN_PORT="${VAULTWARDEN_PORT:-8222}"
command -v docker >/dev/null || { echo "error: docker is not installed" >&2; exit 2; }
command -v openssl >/dev/null || { echo "error: openssl is required to generate the admin token" >&2; exit 2; }
mkdir -p "$SERVICE_DIR/data"
# ── The admin token ───────────────────────────────────────────────────────────────────────────────────
# Kept in a 0600 file inside the service directory rather than only in the database, so the owner can
# still reach the admin page if Officer is down — which is exactly when they might need to.
TOKEN_FILE="$SERVICE_DIR/.admin-token"
if [ -f "$TOKEN_FILE" ]; then
echo "==> Reusing the existing admin token"
VAULTWARDEN_ADMIN_TOKEN="$(cat "$TOKEN_FILE")"
else
echo "==> Generating an admin token"
VAULTWARDEN_ADMIN_TOKEN="$(openssl rand -base64 48 | tr -d '\n')"
( umask 077; printf '%s' "$VAULTWARDEN_ADMIN_TOKEN" > "$TOKEN_FILE" )
fi
# Vaultwarden accepts a plaintext token but warns loudly and recommends an Argon2 hash; `vaultwarden
# hash` does not exist as a standalone binary, so the hash is produced by the image itself. Falling back
# to plaintext rather than failing: a working install with a warning beats no install at all, and the
# token is only reachable over loopback.
echo "==> Hashing it"
if VAULTWARDEN_ADMIN_TOKEN_HASH="$(printf '%s' "$VAULTWARDEN_ADMIN_TOKEN" \
| docker run --rm -i vaultwarden/server:latest /vaultwarden hash --preset owasp 2>/dev/null \
| grep -oE '\$argon2[^ ]*' | head -1)" && [ -n "$VAULTWARDEN_ADMIN_TOKEN_HASH" ]; then
# Compose reads `$` as interpolation, so a literal Argon2 hash must have every `$` doubled or the
# container receives a mangled token and rejects every admin login with no useful error.
VAULTWARDEN_ADMIN_TOKEN_HASH="${VAULTWARDEN_ADMIN_TOKEN_HASH//\$/\$\$}"
else
echo " (could not hash — falling back to a plaintext token, which Vaultwarden will warn about)"
VAULTWARDEN_ADMIN_TOKEN_HASH="$VAULTWARDEN_ADMIN_TOKEN"
fi
# ── Render and start ──────────────────────────────────────────────────────────────────────────────────
echo "==> Preparing $SERVICE_DIR"
export OFFICER_UID OFFICER_GID OFFICER_TZ VAULTWARDEN_PORT VAULTWARDEN_ADMIN_TOKEN_HASH
envsubst '${OFFICER_UID} ${OFFICER_GID} ${OFFICER_TZ} ${VAULTWARDEN_PORT} ${VAULTWARDEN_ADMIN_TOKEN_HASH}' \
< "$TEMPLATE_DIR/docker-compose.yaml" > "$SERVICE_DIR/docker-compose.yaml"
echo "==> Starting the container"
docker compose --project-directory "$SERVICE_DIR" up -d
echo "==> Waiting for Vaultwarden to answer"
for i in $(seq 1 60); do
code="$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:${VAULTWARDEN_PORT}/alive" || true)"
if [ "$code" = "200" ]; then echo " up after ${i}s"; break; fi
if [ "$i" = "60" ]; then
echo "error: Vaultwarden did not answer on 127.0.0.1:${VAULTWARDEN_PORT} within 60s" >&2
echo " check: docker compose --project-directory '$SERVICE_DIR' logs" >&2
exit 1
fi
sleep 1
done
# The URL is all the platform stores. The admin token is deliberately NOT returned: the vault sidecar
# proxies the Bitwarden protocol and never needs it, and a secret the platform does not hold is a secret
# it cannot leak. It is in $TOKEN_FILE for the owner.
echo "OFFICER_RESULT_URL=http://127.0.0.1:${VAULTWARDEN_PORT}"
echo "==> Done. Admin token: $TOKEN_FILE (0600, not stored by Officer)"
+66
View File
@@ -0,0 +1,66 @@
import { describe, expect, it } from 'bun:test';
import { checkServiceUrl } from './url';
// The instance may be anywhere. These tests are mostly about what must NOT be rejected — every case
// below is an ordinary self-hosted setup, and treating any of them as invalid would send someone to
// the docs for no reason.
describe('accepts wherever the service actually is', () => {
const ok = [
'https://photos.example.com', // remote, behind a proxy on 443
'http://10.0.0.5:8096', // another host on the LAN, standard port
'http://100.64.0.1:19999', // tailnet address, arbitrary port
'https://example.com/immich', // sharing a domain, served under a path
'http://127.0.0.1:9091', // same machine, which is allowed — just not assumed
'http://immich:2283', // a docker network hostname
];
for (const url of ok) {
it(`accepts ${url}`, () => {
expect(checkServiceUrl(url).ok).toBe(true);
});
}
});
describe('normalises what is untidy', () => {
it('strips a trailing slash', () => {
// `${url}/api/x` would otherwise double the separator — accepted by some servers, 404 by others,
// which is the kind of difference that reproduces on one machine and not another.
const result = checkServiceUrl('https://photos.example.com/');
expect(result.ok && result.url).toBe('https://photos.example.com');
});
it('drops a query string, which is where a token would hide', () => {
const result = checkServiceUrl('https://x.example.com/?token=secret');
expect(result.ok && result.url).toBe('https://x.example.com');
});
it('keeps a path, because a service under a prefix is normal', () => {
const result = checkServiceUrl('https://example.com/immich/');
expect(result.ok && result.url).toBe('https://example.com/immich');
});
});
describe('rejects only what cannot work', () => {
it('names the missing scheme rather than saying "invalid"', () => {
// The commonest mistake: people type what they type into a browser.
const result = checkServiceUrl('photos.example.com');
expect(result.ok).toBe(false);
if (result.ok) throw new Error('unreachable');
expect(result.error).toContain('https://photos.example.com');
});
it('refuses credentials in the URL', () => {
// They would land in a column meant for a location — outside the encrypted secret, and in every
// log line that ever prints the URL.
expect(checkServiceUrl('https://user:pass@x.example.com').ok).toBe(false);
});
it('refuses a scheme nothing here speaks', () => {
expect(checkServiceUrl('ftp://x.example.com').ok).toBe(false);
});
it('refuses empty', () => {
expect(checkServiceUrl(' ').ok).toBe(false);
});
});
+58
View File
@@ -0,0 +1,58 @@
// Checking and normalising a URL the user typed for a service they already run.
//
// ── What must never be assumed ──
//
// That the instance is on this machine, on its project's usual port, or reachable without a scheme. It
// may be on another host, behind a reverse proxy on 443 with a path, on a tailnet address, or on an
// arbitrary port because 8096 was already taken. Every one of those is an ordinary self-hosted setup,
// and each is a case where guessing produces a connection that fails later with no clue why.
//
// So the rule here is narrow: reject only what CANNOT work, normalise what is merely untidy, and accept
// everything else without opinion. In particular no check that the host is local, that the port matches
// the service's default, or that the scheme is https — a tailnet HTTP service is completely normal.
export type UrlCheck = { ok: true; url: string } | { ok: false; error: string };
/**
* Validate and normalise. Returns the form to store, or why it cannot be stored.
*
* Normalising matters more than it looks: a trailing slash turns `${url}/api/x` into a double
* separator, which some servers accept and others 404 the kind of difference that shows up on one
* user's machine and not another's.
*/
export function checkServiceUrl(raw: string): UrlCheck {
const trimmed = raw.trim();
if (!trimmed) return { ok: false, error: 'A URL is required.' };
// The commonest mistake, and worth naming precisely rather than as "invalid URL": people type the
// host alone because that is what they type into a browser.
if (!/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(trimmed)) {
return { ok: false, error: `Include the scheme — "https://${trimmed}" or "http://${trimmed}".` };
}
let parsed: URL;
try {
parsed = new URL(trimmed);
} catch {
return { ok: false, error: 'That is not a valid URL.' };
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return { ok: false, error: `Only http and https are supported, not "${parsed.protocol.replace(':', '')}".` };
}
if (!parsed.hostname) return { ok: false, error: 'That URL has no host.' };
// Credentials in the URL are dropped rather than stored: they would end up in a database column meant
// for a location, outside the encrypted `secret`, and in any log line that ever prints the URL.
if (parsed.username || parsed.password) {
return {
ok: false,
error: 'Put the username and password in their own fields rather than in the URL.',
};
}
// Trailing slash off, query and fragment dropped — neither means anything for a service base, and a
// stored `?token=…` would be a credential hiding in the wrong column.
const path = parsed.pathname.replace(/\/+$/, '');
return { ok: true, url: `${parsed.protocol}//${parsed.host}${path}` };
}
+23 -1
View File
@@ -76,11 +76,30 @@ export async function getEffectiveCapabilities(userId: number | undefined): Prom
const grants = new Map<string, CapabilityLevel>(); const grants = new Map<string, CapabilityLevel>();
for (const capability of CORE_CAPABILITIES) grants.set(capability.key, 'write'); for (const capability of CORE_CAPABILITIES) grants.set(capability.key, 'write');
// Whether the kernel can enforce a boundary for this account. `confined` capabilities are dropped
// without it — see below.
const hasOsAccount = !!user.osUser;
for (const [key, level] of await grantsForRole(user.role)) { for (const [key, level] of await grantsForRole(user.role)) {
const capability = CAPABILITY_BY_KEY.get(key); const capability = CAPABILITY_BY_KEY.get(key);
// Unknown key: a capability that was renamed or removed while a grant survived. Ignore it — the // Unknown key: a capability that was renamed or removed while a grant survived. Ignore it — the
// alternative is honouring a name nothing defines. // alternative is honouring a name nothing defines.
if (!capability) continue; if (!capability) continue;
// A confined capability touches the filesystem or runs a process, and is safe only because the
// account has its own Linux user to be confined to. Without one there is no boundary, so the grant
// resolves to nothing rather than to the owner's home — which is what it WOULD resolve to, since
// `getOwnerHomeDir` ignores the email it is passed whenever HOME_DIR is set.
//
// Dropped here rather than refused per-router so that one rule covers the HTTP routes, the
// websocket doors and the dock all at once. A member with `files` granted but no OS account sees no
// Files icon, gets a 403 from /api/file-browser, and cannot open the terminal socket — from this.
if (capability.kind === 'confined') {
if (!hasOsAccount) continue;
grants.set(key, level);
continue;
}
if (capability.kind !== 'app') continue; if (capability.kind !== 'app') continue;
grants.set(key, level); grants.set(key, level);
} }
@@ -145,6 +164,9 @@ export async function isWsProviderAllowed(userId: number | undefined, provider:
if (isOwner) return true; if (isOwner) return true;
const capability = capabilityForWsProvider(provider); const capability = capabilityForWsProvider(provider);
if (!capability || capability.kind !== 'app') return false; // `confined` is admissible here as well as `app`: getEffectiveCapabilities has already dropped confined
// grants for an account with no Linux user, so reaching this line with one in `grants` means the boundary
// exists. Anything still `execution` is refused structurally, by not being in the map at all.
if (!capability || (capability.kind !== 'app' && capability.kind !== 'confined')) return false;
return grants.has(capability.key); return grants.has(capability.key);
} }
+26 -1
View File
@@ -154,12 +154,37 @@ describe('self-service routes', () => {
describe('kinds', () => { describe('kinds', () => {
test('execution capabilities are never grantable', () => { test('execution capabilities are never grantable', () => {
const grantable = new Set(GRANTABLE_CAPABILITIES.map((c) => c.key)); const grantable = new Set(GRANTABLE_CAPABILITIES.map((c) => c.key));
for (const key of ['terminal', 'chat', 'files', 'tasks', 'desktop', 'browser', 'items']) { // `files` left this list on 2026-08-11, then `terminal` and `chat` the same day — see the tests below and
// docs/per-user-linux-accounts.md. Everything still here runs as the OWNER in the owner's home with no
// per-caller resolution at all.
for (const key of ['tasks', 'desktop', 'browser', 'items']) {
expect(CAPABILITY_BY_KEY.get(key)?.kind).toBe('execution'); expect(CAPABILITY_BY_KEY.get(key)?.kind).toBe('execution');
expect(grantable.has(key)).toBe(false); expect(grantable.has(key)).toBe(false);
} }
}); });
// A confined capability is grantable, but the grant is inert without a Linux account — enforced in
// authorize.ts, which is where the rule can cover routes, sockets and the dock at once.
test('confined capabilities are grantable', () => {
const grantable = new Set(GRANTABLE_CAPABILITIES.map((c) => c.key));
for (const key of ['files', 'terminal', 'chat']) {
expect(CAPABILITY_BY_KEY.get(key)?.kind).toBe('confined');
expect(grantable.has(key)).toBe(true);
}
});
// The claim `confined` makes is that every path it reaches resolves its directory from the CALLER. That
// cannot be asserted from the registry, so this pins the inverse: nothing becomes confined without a
// deliberate edit here, and the list stays short enough to audit by eye.
//
// `chat` was on this list ahead of its implementation for a day, with `api/chat/chat.ts` and the chat socket
// both refusing non-owners because a turn still spawned the agent as the owner. Both refusals were removed
// on 2026-08-12, together, once the turn ran under `runAs` with the member's own home, credential,
// transcripts and session ownership. `chat` is now confined in fact and not only in the registry.
test('confined is a short, deliberate list', () => {
expect(CAPABILITIES.filter((c) => c.kind === 'confined').map((c) => c.key)).toEqual(['terminal', 'chat', 'files']);
});
test('admin capabilities are never grantable', () => { test('admin capabilities are never grantable', () => {
const grantable = new Set(GRANTABLE_CAPABILITIES.map((c) => c.key)); const grantable = new Set(GRANTABLE_CAPABILITIES.map((c) => c.key));
for (const key of ['user-admin', 'server-admin', 'wallet', 'headscale']) { for (const key of ['user-admin', 'server-admin', 'wallet', 'headscale']) {
+94 -21
View File
@@ -20,9 +20,26 @@
// core every authenticated account, always. Not grantable because not deniable — signing in // core every authenticated account, always. Not grantable because not deniable — signing in
// without them means a broken app, not a restricted one. // without them means a broken app, not a restricted one.
// app the grantable surface. This is what the owner hands out per role. // app the grantable surface. This is what the owner hands out per role.
// confined execution-shaped, but the KERNEL enforces the boundary per account. Grantable, and only
// to an account that has a Linux user — see below.
// execution NEVER grantable. Owner only, structurally. // execution NEVER grantable. Owner only, structurally.
// admin owner only: the platform administering itself, and the owner's own money and network. // admin owner only: the platform administering itself, and the owner's own money and network.
// //
// ── `confined`, and why it is not just `app` ──
//
// Added 2026-08-11 with per-user Linux accounts (docs/per-user-linux-accounts.md). A confined capability
// touches the filesystem or runs a process, so calling it an `app` would be a lie — but it is no longer
// the OWNER'S filesystem, because the account has its own Linux user, its own home, and the kernel refusing
// everything above it.
//
// The distinction earns its keep in one place: a grant on a confined capability means NOTHING unless the
// account actually has that Linux user. `authorize.ts` drops confined grants for an account with no
// `osUser`, so "granted but unconfined" resolves to no access rather than to the owner's home. That rule
// lives there, once, instead of in each router that would otherwise have to remember it.
//
// Moving a capability from `execution` to `confined` is therefore a claim with a test attached: every path
// it reaches must resolve its directory from the CALLER, not from HOME_DIR.
//
// `execution` is the important one. Everything under it runs as the OWNER'S OS user in the owner's home // `execution` is the important one. Everything under it runs as the OWNER'S OS user in the owner's home
// directory: the terminal is a real shell, chat spawns `claude` with --dangerously-skip-permissions, tasks // directory: the terminal is a real shell, chat spawns `claude` with --dangerously-skip-permissions, tasks
// run arbitrary scripts, the file browser and code editor read and write the owner's disk, the desktop is // run arbitrary scripts, the file browser and code editor read and write the owner's disk, the desktop is
@@ -38,7 +55,7 @@
// in the sidecar contract because every sidecar request carries `X-Officer-User`. So "may a member write // in the sidecar contract because every sidecar request carries `X-Officer-User`. So "may a member write
// here" is a property of the endpoint, not a policy knob someone has to remember to set. // here" is a property of the endpoint, not a policy knob someone has to remember to set.
export type CapabilityKind = 'core' | 'app' | 'execution' | 'admin'; export type CapabilityKind = 'core' | 'app' | 'confined' | 'execution' | 'admin';
export type Capability = { export type Capability = {
/** Stable identifier. Stored in the database as the grant's subject — renaming one is a data change. */ /** Stable identifier. Stored in the database as the grant's subject — renaming one is a data change. */
@@ -218,48 +235,70 @@ export const CAPABILITIES: Capability[] = [
// bound to the caller. Administering the tailnet is `headscale`, which is admin-only. // bound to the caller. Administering the tailnet is `headscale`, which is admin-only.
personal: ['/'], personal: ['/'],
}, },
// Core, not app — and this was a real defect, not a preference. `/api/dashboards` is not a feature, it is
// the per-user key-value store where EVERY workspace screen keeps its layout (`screens/files`,
// `ws-layout-*`, panel config). `WorkspaceView` renders nothing until that store has loaded, so gating it
// meant a member with `files` granted got a completely blank Files screen and no request to
// /api/file-browser at all — the panel never mounted. Same for Terminal, Chat and every other screen.
//
// It is entirely `personal` and always was: every row is keyed to the caller. There is nothing here to
// withhold, and withholding it does not restrict an account, it breaks it — which is exactly the
// definition of `core` at the top of this file.
{ {
key: 'dashboards', key: 'dashboards',
label: 'Dashboards', label: 'Screen layouts and dashboards',
description: 'Your own dashboards and saved layouts', description: 'Where your own screen layouts and dashboards are saved',
kind: 'app', kind: 'core',
api: ['/dashboards'], api: ['/dashboards'],
routes: ['/dashboards'], routes: ['/dashboards'],
personal: ['/'], personal: ['/'],
}, },
{
key: 'plans',
label: 'Plans',
description: 'Plan documents',
kind: 'app',
api: ['/plans'],
routes: ['/plans'],
},
// ── execution: never grantable ────────────────────────────────────────────────────────────────── // ── execution: never grantable ──────────────────────────────────────────────────────────────────
// Confined since 2026-08-11. A member's shell is spawned by the pty sidecar through `sudo setpriv` as their
// own Linux account, in their own home, with the platform's environment cleared — so it is their shell, and
// the kernel decides what it can reach. The sidecar also records whose each session is, so `list` and `kill`
// scope to the caller instead of every shell on the box.
//
// What this is NOT is a jail. A member with a shell can `cd /` and read whatever the system leaves
// world-readable, like any account on any machine. It isolates members from each other and from the owner's
// files, which is the promise `confined` makes.
{ {
key: 'terminal', key: 'terminal',
label: 'Terminal', label: 'Terminal',
description: 'A real shell as the server owner', description: 'A shell on this machine, as your own user',
kind: 'execution', kind: 'confined',
api: ['/terminal'], api: ['/terminal'],
ws: ['terminal'], ws: ['terminal'],
routes: ['/terminal'], routes: ['/terminal'],
}, },
// Confined, and true since 2026-08-12: a member's turn spawns their own `claude` as their own Linux account
// through `sudo setpriv`, with their own `~/.claude` credential, their own transcripts and sessions that
// record whose they are. The two owner-only refusals that held this open — `api/chat/chat.ts` and the socket
// in `server.tsx` — were removed together once each of those was in place.
{ {
key: 'chat', key: 'chat',
label: 'Chat', label: 'Chat',
description: 'The agent, running unsandboxed as the server owner', description: 'The agent',
kind: 'execution', kind: 'confined',
api: ['/chat'], // `/agent-status` is on this grant but deliberately NOT on `chatRouter`, because that router refuses
// non-owners wholesale — so an endpoint there could not be read by the accounts that need it. It reports
// two booleans about the caller's own home and nothing about anyone else, which is what lets a member be
// told "run `claude` once to sign in" instead of being shown a tile that 403s.
api: ['/chat', '/agent-status'],
ws: ['chat'], ws: ['chat'],
routes: ['/chat'], routes: ['/chat'],
}, },
// Confined rather than execution since 2026-08-11. Every path under `/file-browser` resolves its root
// through `resolveHomeDir(userId)` in a middleware that refuses the request outright when the account has
// no Linux user — so a member sees their own home and `resolveUserPath`'s containment check stops them
// walking out of it. `/upload` was already per-caller: it writes only under
// `DATA_PATH/<email>/attachments`, never into a home.
{ {
key: 'files', key: 'files',
label: 'Files', label: 'Files',
description: "The server owner's filesystem, and the code editor over it", description: 'Your own home directory on this machine, and the code editor over it',
kind: 'execution', kind: 'confined',
api: ['/file-browser', '/upload'], api: ['/file-browser', '/upload'],
routes: ['/files', '/code-editor'], routes: ['/files', '/code-editor'],
}, },
@@ -298,6 +337,18 @@ export const CAPABILITIES: Capability[] = [
}, },
// ── admin: the platform administering itself ──────────────────────────────────────────────────── // ── admin: the platform administering itself ────────────────────────────────────────────────────
{
key: 'app-store',
label: 'App store',
description: 'Install, enable and remove the sidecars this server runs',
// Admin, not app. Installing a sidecar starts a process on the machine and provisioning one starts
// containers — that is process control, not a feature a member can be granted a read of. The router
// gates on the owner in its own right as well; this entry is what makes the boot check pass and what
// keeps the surface visible in one enumeration.
kind: 'admin',
api: ['/app-store'],
routes: ['/app-store'],
},
{ {
key: 'server-admin', key: 'server-admin',
label: 'Server settings', label: 'Server settings',
@@ -339,8 +390,30 @@ export const CAPABILITIES: Capability[] = [
export const CAPABILITY_BY_KEY = new Map(CAPABILITIES.map((c) => [c.key, c])); export const CAPABILITY_BY_KEY = new Map(CAPABILITIES.map((c) => [c.key, c]));
/** The keys an owner may actually hand to a role. `core` is automatic, the other two are owner-only. */ /**
export const GRANTABLE_CAPABILITIES = CAPABILITIES.filter((c) => c.kind === 'app'); * The keys an owner may actually hand to a role. `core` is automatic; `execution` and `admin` are owner-only.
*
* `confined` is offered here, but a grant on one is inert for an account without a Linux user that is
* enforced in `authorize.ts`, not by withholding it from this list. Withholding it would mean the owner
* could not pre-grant a role before provisioning the people in it, which is the normal order of operations.
*/
export const GRANTABLE_CAPABILITIES = CAPABILITIES.filter((c) => c.kind === 'app' || c.kind === 'confined');
/**
* What every role starts with on a fresh install: the three confined capabilities, at `write`.
*
* These are the baseline the platform is FOR a terminal, a file browser and chat. An account that can sign
* in and reach none of them is not a restricted account, it is a useless one, and making the owner grant them
* by hand before anyone can do anything is a step with no decision in it.
*
* Seeded as real rows rather than implied by absence, which keeps the table's one rule intact: a missing row
* means no access, always, with no exceptions to remember. So revoking one of these works exactly like
* revoking anything else the row goes, and nothing puts it back.
*
* `app` capabilities are deliberately NOT here. Those reach data the owner may not intend to share, and each
* needs a sidecar installed before it means anything anyway.
*/
export const DEFAULT_ROLE_CAPABILITIES: string[] = CAPABILITIES.filter((c) => c.kind === 'confined').map((c) => c.key);
/** Available to every signed-in account without a grant. */ /** Available to every signed-in account without a grant. */
export const CORE_CAPABILITIES = CAPABILITIES.filter((c) => c.kind === 'core'); export const CORE_CAPABILITIES = CAPABILITIES.filter((c) => c.kind === 'core');
+5 -3
View File
@@ -18,8 +18,8 @@ type ClaudeCodeResult = {
cost: MessageCost; cost: MessageCost;
}; };
export function clearClaudeCodeSession(sessionKey: string): void { export function clearClaudeCodeSession(sessionKey: string, userId: number): void {
sidecar.clearClaudeSession(sessionKey); sidecar.clearClaudeSession(sessionKey, userId);
} }
export async function sendClaudeCode(params: ClaudeCodeParams): Promise<ClaudeCodeResult> { export async function sendClaudeCode(params: ClaudeCodeParams): Promise<ClaudeCodeResult> {
@@ -40,6 +40,8 @@ type ClaudeCodeStreamingParams = {
model?: string; model?: string;
resumeSessionId?: string; resumeSessionId?: string;
durable?: boolean; durable?: boolean;
/** Whose Linux account the turn runs as. Undefined for the owner; resolved by the caller, never a client. */
member?: { osUser: string; home: string };
// Finished turn messages, already committed by the sidecar; `seq` is the cursor id to deliver them under. // Finished turn messages, already committed by the sidecar; `seq` is the cursor id to deliver them under.
onMessage: (msg: TurnMessage, seq?: number) => void; onMessage: (msg: TurnMessage, seq?: number) => void;
}; };
@@ -75,7 +77,7 @@ export async function sendClaudeCodeStreaming(params: ClaudeCodeStreamingParams)
return { return {
kill: () => { kill: () => {
sidecar.killClaude(params.sessionKey); sidecar.killClaude(params.sessionKey, params.userId);
unsub(); unsub();
}, },
detach: unsub, detach: unsub,
+10 -1
View File
@@ -4,7 +4,16 @@ import { Hono } from 'hono';
export type HonoVariables = { export type HonoVariables = {
body: Record<string, unknown>; body: Record<string, unknown>;
origin: string; origin: string;
user: User; /**
* The authenticated account, plus on routes that resolve it the home directory this request is
* confined to.
*
* `homeDir` is optional because only the file browser's `confineToHome` middleware sets it, and it lives
* on `user` rather than in its own variable for a blunt reason: `getRootDir(user, root)` is called from
* fifteen places in that router, and the cost of missing one is serving the OWNER'S home to a member.
* Carrying it on the object those call sites already receive means none of them can forget.
*/
user: User & { homeDir?: string };
}; };
export const createRouter = () => new Hono<{ Variables: HonoVariables }>(); export const createRouter = () => new Hono<{ Variables: HonoVariables }>();
+50 -1
View File
@@ -1,5 +1,5 @@
import { join, resolve } from 'node:path'; import { join, resolve } from 'node:path';
import { mkdirSync } from 'node:fs'; import { chmodSync, mkdirSync } from 'node:fs';
import { homedir } from 'node:os'; import { homedir } from 'node:os';
export const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data'); export const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data');
@@ -42,6 +42,55 @@ export const getHomeDir = (email: string) => join(DATA_PATH, email, 'home');
// terminals/chats/tasks share config and credentials with the shell they use outside Officer. // terminals/chats/tasks share config and credentials with the shell they use outside Officer.
export const getOwnerHomeDir = (email: string): string => process.env.HOME_DIR ?? getHomeDir(email); export const getOwnerHomeDir = (email: string): string => process.env.HOME_DIR ?? getHomeDir(email);
// The directory skeleton a new account gets under DATA_PATH.
//
// Most of these are also created on demand by whichever feature owns them, so pre-creating them buys
// legibility more than function — the tree shows what an account has without it having to be used first.
// `home` is the exception and the reason this exists: nothing else creates it, and it is where a
// non-owner's sessions would run.
//
// Single-sourced here rather than in the script that used to own the list, because there are now two
// callers — `scripts/provision-user-dirs.ts` and the owner's create-account handler — and a skeleton
// that differs depending on how the account was made is a bug nobody would think to look for.
export const USER_DIRS = ['home', 'attachments', 'cache', 'dashboards', 'email_accounts', 'logs', 'sidecar'] as const;
/**
* Create an account's root and its skeleton, closed by default.
*
* Keyed on email because that is what the on-disk layout uses everywhere else (`DATA_PATH/<email>/…`).
* Renaming an account's email would orphan its directory; that is pre-existing and not this function's
* problem, but it is the reason nothing here derives a path from the id.
*
* Why the modes are set here and not only by os-user.ts
*
* `711` on the account directory, `700` on everything inside it. Measured while testing per-user Linux
* accounts: at the default umask these came out `755`, and a member with a shell could read ANOTHER
* member's home directory just by naming it the parent being unlistable is not protection when the
* child itself is world-readable. "Locked unless something opens it" has to be the resting state, so it
* belongs at creation rather than in the confinement pass, which only ever runs for accounts that have an
* OS user.
*
* `chmod` explicitly rather than mkdir's `mode`, which is masked by the umask and does nothing at all for
* a directory that already exists.
*/
export const provisionUserDirs = (email: string): void => {
const accountDir = join(DATA_PATH, email);
for (const dir of USER_DIRS) mkdirSync(join(accountDir, dir), { recursive: true });
// Traversable, not listable: reaching `home` must not mean enumerating the platform's tree beside it.
chmodSync(accountDir, 0o711);
for (const dir of USER_DIRS) {
try {
chmodSync(join(accountDir, dir), 0o700);
} catch {
// A directory that is no longer OURS to chmod. `home` becomes the member's on the first successful
// provision, and `chmod` requires ownership — so re-running this threw EPERM and took every RETRY down
// before it began, which is how this was found. os-user.ts sets the home's mode through sudo and is the
// authority for it; here the mode is a default for directories we are creating, not an assertion about
// ones that already exist.
}
}
};
export const getTmpAttachmentsDir = (email: string) => join(DATA_PATH, email, 'attachments', 'tmp'); export const getTmpAttachmentsDir = (email: string) => join(DATA_PATH, email, 'attachments', 'tmp');
+4 -2
View File
@@ -8,7 +8,6 @@ import { landingPageDataRouter } from './api/landing-page-data/landing-page-data
import { waitlistRouter } from './api/waitlist/waitlist'; import { waitlistRouter } from './api/waitlist/waitlist';
import { usersRouter } from './api/users/users-router'; import { usersRouter } from './api/users/users-router';
import { apiKeysRouter } from './api/api-keys/router'; import { apiKeysRouter } from './api/api-keys/router';
import { plansRouter } from './api/plans/plans';
import { skillsRouter } from './api/skills/skills'; import { skillsRouter } from './api/skills/skills';
import { tasksRouter } from './api/tasks/tasks'; import { tasksRouter } from './api/tasks/tasks';
import { agentsRouter } from './api/agents/agents'; import { agentsRouter } from './api/agents/agents';
@@ -37,6 +36,7 @@ import { terminalRouter } from './api/terminal/sidecar-server';
import { caldavRouter } from './api/dav/sidecar-server'; import { caldavRouter } from './api/dav/sidecar-server';
import { memosRouter } from './api/memos/router'; import { memosRouter } from './api/memos/router';
import { giteaRouter } from './api/gitea/router'; import { giteaRouter } from './api/gitea/router';
import { appStoreRouter } from './api/app-store/router';
import { davSyncRouter } from './api/dav/sync-router'; import { davSyncRouter } from './api/dav/sync-router';
import { davRouter } from './api/dav/router'; import { davRouter } from './api/dav/router';
import { claimIosProfile } from './api/dav/ios-profile'; import { claimIosProfile } from './api/dav/ios-profile';
@@ -53,6 +53,7 @@ import { emailRouter } from './api/email/router';
import { browserRouter } from './api/browser/router'; import { browserRouter } from './api/browser/router';
import { desktopRouter } from './api/desktop/rest'; import { desktopRouter } from './api/desktop/rest';
import { bugReportRouter } from './api/bug-report/bug-report'; import { bugReportRouter } from './api/bug-report/bug-report';
import { agentStatusRouter } from './api/agent-status/router';
import { chatRouter } from './api/chat/chat'; import { chatRouter } from './api/chat/chat';
import { pipelineJobsRouter } from './api/tasks/pipeline-jobs-routes'; import { pipelineJobsRouter } from './api/tasks/pipeline-jobs-routes';
import { CustomError } from './custom-errors'; import { CustomError } from './custom-errors';
@@ -189,7 +190,6 @@ protectedRouter.use(userMiddleware);
const PROTECTED_MOUNTS: [prefix: string, router: ReturnType<typeof createRouter>][] = [ const PROTECTED_MOUNTS: [prefix: string, router: ReturnType<typeof createRouter>][] = [
['/server-settings', serverSettingsRouter], ['/server-settings', serverSettingsRouter],
['/users', usersRouter], ['/users', usersRouter],
['/plans', plansRouter],
['/skills', skillsRouter], ['/skills', skillsRouter],
['/tasks', tasksRouter], ['/tasks', tasksRouter],
['/agents', agentsRouter], ['/agents', agentsRouter],
@@ -208,6 +208,7 @@ const PROTECTED_MOUNTS: [prefix: string, router: ReturnType<typeof createRouter>
['/terminal', terminalRouter], ['/terminal', terminalRouter],
['/memos', memosRouter], ['/memos', memosRouter],
['/gitea', giteaRouter], ['/gitea', giteaRouter],
['/app-store', appStoreRouter],
['/caldav', caldavRouter], // the JSON door for Officer's own calendar/contacts UI ['/caldav', caldavRouter], // the JSON door for Officer's own calendar/contacts UI
['/dav', davRouter], // app-password management (the sync door is /dav, top-level) ['/dav', davRouter], // app-password management (the sync door is /dav, top-level)
['/notify', notifyRouter], ['/notify', notifyRouter],
@@ -226,6 +227,7 @@ const PROTECTED_MOUNTS: [prefix: string, router: ReturnType<typeof createRouter>
['/email', emailRouter], ['/email', emailRouter],
['/browser', browserRouter], ['/browser', browserRouter],
['/bug-report', bugReportRouter], ['/bug-report', bugReportRouter],
['/agent-status', agentStatusRouter],
['/chat', chatRouter], ['/chat', chatRouter],
['/pipeline-jobs', pipelineJobsRouter], ['/pipeline-jobs', pipelineJobsRouter],
['/jobs', pipelineJobsRouter], // unified jobs API (script + pipeline); /pipeline-jobs kept for the existing UI ['/jobs', pipelineJobsRouter], // unified jobs API (script + pipeline); /pipeline-jobs kept for the existing UI
+49
View File
@@ -0,0 +1,49 @@
import { describe, expect, test } from 'bun:test';
import { parseLoginProbe } from './os-user-claude';
// Pins the shape of the answer `/agent-status` gives a member about their own agent.
//
// The version before this one decided with `out.includes('bin')` / `out.includes('cred')` against a string
// that merged stdout and stderr — and both markers are substrings of the paths the probe tests. A shell trace
// echoing the command was enough to report a member as installed and signed in when neither was true,
// verified on the live server against an account that had never logged in. It failed in the unsafe direction:
// the endpoint exists to explain a broken agent, and that is the mode where it explains the wrong thing.
//
// So the assertions below are mostly about what CANNOT move the answer.
describe('parseLoginProbe', () => {
test('reads both flags by position', () => {
expect(parseLoginProbe('11')).toEqual({ installed: true, loggedIn: true });
expect(parseLoginProbe('10')).toEqual({ installed: true, loggedIn: false });
expect(parseLoginProbe('01')).toEqual({ installed: false, loggedIn: true });
expect(parseLoginProbe('00')).toEqual({ installed: false, loggedIn: false });
});
test('the freshly provisioned case: installed, not signed in', () => {
// What a member sees before they run `claude` once themselves — the case the UI renders instructions for.
expect(parseLoginProbe('10')).toEqual({ installed: true, loggedIn: false });
});
test('a trace of the probe cannot set either flag', () => {
// Exactly the stderr the live server captured under `sh -x`, now on the channel the answer is read from.
// Under the old substring match this returned true/true.
const trace =
'+ test -x /data/jg@pertento.ai/home/.local/bin/claude\n' +
'+ test -s /data/jg@pertento.ai/home/.claude/.credentials.json\n';
expect(parseLoginProbe(trace)).toEqual({ installed: false, loggedIn: false });
});
test('the paths themselves cannot set either flag', () => {
expect(parseLoginProbe('/home/x/.local/bin/claude')).toEqual({ installed: false, loggedIn: false });
expect(parseLoginProbe('/home/x/.claude/.credentials.json')).toEqual({ installed: false, loggedIn: false });
});
test('no output refuses rather than assuming', () => {
// A failed spawn must not read as a working agent.
expect(parseLoginProbe('')).toEqual({ installed: false, loggedIn: false });
});
test('a sudo banner or any other prefix cannot shift the positions into truth', () => {
expect(parseLoginProbe('sudo: a password is required\n')).toEqual({ installed: false, loggedIn: false });
});
});
+158
View File
@@ -0,0 +1,158 @@
import { join } from 'node:path';
import { osUserHome, runAs } from './os-user';
// Claude, per member: their own binary, their own login, in their own home.
//
// ── Why not one shared binary ──
//
// A single `/usr/local/bin/claude` would be less disk and one version to reason about, and the argument for
// it is real: the private part of Claude is the credential in `~/.claude`, not the executable. It is still
// the wrong shape here. `claude` updates itself — that is why the owner's own install goes through
// Anthropic's installer rather than npm (`scripts/setup.sh:853`) — and a root-owned binary is one a member
// cannot update, which turns "my agent is a version behind" into a request to the owner. Per-member also
// means the account's agent keeps working exactly as the tool ships, with no platform-shaped exception to
// explain. Same command the owner ran, run as them, in their home.
//
// ── The credential is theirs, and this is what makes that true ──
//
// A member's `claude` must never see the owner's Anthropic proxy. `sidecar/claude/user-instance.ts:148`
// sets `ANTHROPIC_BASE_URL`, `ANTHROPIC_API_KEY` and `_CLAUDE_CODE_ASSUME_FIRST_PARTY_BASE_URL` on the agent
// sidecar's own `process.env`, so anything spawned from that process inherits the owner's credential by
// default — the leak would be the absence of an action, not an action. `runAs` closes it structurally:
// `--reset-env` clears the environment on the way through `setpriv`, so a variable reaches a member only
// because someone wrote it into the command (`os-user.ts:120`). Default deny, and nothing to remember.
//
// ── Login is the member's own, and cannot be done for them ──
//
// `claude` authenticates interactively against an account. The platform therefore cannot log a member in,
// and should not want to: their subscription is theirs. All this file can do is install the binary and
// report whether the credential has appeared, so the UI can render the one-line instruction instead of an
// agent that fails for reasons nobody can see.
/** Anthropic's own installer — the same one `scripts/setup.sh` uses for the owner, chosen for auto-update. */
const CLAUDE_INSTALL_URL = 'https://claude.ai/install.sh';
/**
* Where the installer puts it, given a home. Also the first path `claude-manager.ts` probes after
* `$CLAUDE_BIN`.
*
* Takes a home rather than an email because the spawn side only ever has the home it comes from
* `resolveHomeDir`, not from a lookup. One derivation for both sides: installing to one path and exec'ing
* another is the kind of divergence that surfaces as "the agent works for some members".
*/
export const claudeBinIn = (home: string): string => join(home, '.local', 'bin', 'claude');
/** The same path, for callers that hold an email. */
export const claudeBinPath = (email: string): string => claudeBinIn(osUserHome(email));
/**
* The file whose existence means "this account has logged in".
*
* `~/.claude.json` is not the marker it holds settings and history and appears on first run, logged in or
* not. `~/.credentials.json` is written by a completed login and is mode 600, which is also why this is
* checked by running as the member rather than by reading it: we need to know the credential is there, never
* what is in it.
*/
const credentialsPath = (email: string): string => join(osUserHome(email), '.claude', '.credentials.json');
/**
* Run a command as the member.
*
* `out` merges stdout and stderr and exists for logging a failure is diagnosable only if both are in it.
* `stdout` is kept separate for anything that *decides* on output, because merging the two channels means the
* decision can be moved by anything that writes to stderr: a shell trace, a sudo banner, a wrapper echoing
* argv. Never match on `out`.
*/
async function asMember(osUser: string, command: string[]): Promise<{ ok: boolean; out: string; stdout: string }> {
const proc = runAs(osUser, command);
const [out, err] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
return { ok: (await proc.exited) === 0, out: `${out}${err}`.trim(), stdout: out };
}
/**
* Read the two-character probe below: position 0 is the binary, position 1 is the credential.
*
* Split out as a pure function so the parsing is testable without a subprocess, and more to the point
* so it can only ever see stdout. The previous version decided with `out.includes('bin')` and
* `out.includes('cred')` against the merged channel, and both markers are substrings of the paths being
* tested: `bin` `…/.local/bin/claude`, `cred` `…/.claude/.credentials.json`. One `set -x` and the trace
* of the test command itself set both flags true with neither file present verified on the live server
* against an account that had never logged in.
*
* No marker spelling fixes that, because a trace echoes the literal along with the path. The channel was the
* bug, so the fix is the channel plus reading by position rather than by substring.
*/
export function parseLoginProbe(stdout: string): ClaudeLoginState {
return { installed: stdout[0] === '1', loggedIn: stdout[1] === '1' };
}
export type ClaudeProvisionResult =
/** `wrote` is false when the binary was already there — a reprovision must not re-download. */
{ ok: true; binPath: string; wrote: boolean } | { ok: false; error: string };
/**
* Install `claude` into the member's home, as the member.
*
* Idempotent by skipping outright when the binary is present, rather than by re-running the installer: the
* retry button reprovisions an account whenever the owner presses it, and re-downloading would both cost a
* network round trip per press and quietly move a member off the version they had chosen by updating.
*
* Never throws. An account with no agent is still a working account the same posture as SSH keys and
* rootless Docker in `provisionOsAccount`.
*/
export async function provisionClaudeCli(params: { email: string; osUser: string }): Promise<ClaudeProvisionResult> {
const binPath = claudeBinPath(params.email);
const present = await asMember(params.osUser, ['test', '-x', binPath]);
if (present.ok) return { ok: true, binPath, wrote: false };
// Piped into `bash`, not `sh`. A script read on stdin never has its shebang honoured — the interpreter you
// name is the one that runs it — and `install.sh` declares `#!/bin/bash` and uses `[[ … =~ … ]]` on line 9.
// On Ubuntu `/bin/sh` is dash, so `| sh` died with `Syntax error: "(" unexpected`, which reads like a broken
// download rather than the wrong interpreter. Reproduced on the live server: `dash -n` fails there, `bash -n`
// is clean.
const install = await asMember(params.osUser, ['sh', '-c', `set -e; curl -fsSL ${CLAUDE_INSTALL_URL} | bash`]);
// The installer's exit code is not the gate — the same lesson as rootless Docker in
// `docs/per-user-linux-accounts.md`. What matters is whether the binary is now there and runnable.
const installed = await asMember(params.osUser, ['test', '-x', binPath]);
if (!installed.ok) {
return { ok: false, error: `claude did not install for ${params.osUser}: ${install.out || 'no output'}` };
}
return { ok: true, binPath, wrote: true };
}
export type ClaudeLoginState = {
/** The binary is present and executable in their home. */
installed: boolean;
/** A completed login has written credentials. False means the member has to run `claude` once themselves. */
loggedIn: boolean;
};
/**
* Whether this account can actually run an agent turn.
*
* Both halves are read as the member, so a `true` here means the member's own process can reach these files
* which is the thing the answer is used to promise. Checking as root would confirm the file exists while
* saying nothing about whether the account that needs it can see it.
*/
export async function claudeLoginState(params: { email: string; osUser: string }): Promise<ClaudeLoginState> {
// One `runAs` for both answers, not two. Each is a `sudo -n setpriv` fork/exec that writes a line to
// `/var/log/auth.log`, and this is reached from `/agent-status`, which sits on a grant every role has by
// default — so a UI that polls it would otherwise cost two sudo spawns and two auth-log lines per poll, per
// member. Individually cheap, unbounded in aggregate, and the auth log is where a real sudo event has to
// stay visible.
//
// Two characters on stdout, read by position — see `parseLoginProbe` for why not markers. `-x` follows
// symlinks, which is what the installer produces: a link into a versioned directory, not a file.
const probe = await asMember(params.osUser, [
'sh',
'-c',
'if test -x "$1"; then printf 1; else printf 0; fi; if test -s "$2"; then printf 1; else printf 0; fi',
'_',
claudeBinPath(params.email),
credentialsPath(params.email),
]);
return parseLoginProbe(probe.stdout);
}
+236
View File
@@ -0,0 +1,236 @@
import { existsSync } from 'node:fs';
import { runAs } from './os-user';
// A member's own Docker: their daemon, their images, their containers, running as their uid.
//
// ── Why rootless, and why the alternative is not on the table ──
//
// The one-line version of "give the user Docker" is `usermod -aG docker <user>`, and it is root. Membership of
// that group means talking to the host daemon, which runs as root, so:
//
// docker run -v /:/host -it alpine chroot /host
//
// is a root shell on the machine. It reads the platform's `.env`, every other member's home, the wallet seed
// — every boundary in docs/per-user-linux-accounts.md, bypassed by one documented command. The group is not
// "access to Docker", it is "root, by a longer route".
//
// Rootless gives the thing that was actually wanted: a daemon per account, containers in that account's user
// namespace, images in their own home. Root inside their container is their uid outside it, which is nobody.
// They cannot see the owner's containers and the owner cannot break theirs.
//
// ── What it needs from the host ──
//
// uidmap newuidmap/newgidmap, to map subordinate ids. Rootless cannot start without them.
// /etc/subuid,gid a range per account. `useradd` allocates one automatically wherever login.defs sets
// SUB_UID_COUNT (Ubuntu does), and `userdel` reclaims it — verified on this host.
// linger `loginctl enable-linger`, or the daemon dies with the session. Officer's shells are
// NOT login sessions, so without this a member's Docker would stop the moment their
// terminal closed, which is the opposite of a daemon.
// dbus-user-session systemd --user needs a bus to talk to.
//
// ── The costs, stated rather than discovered ──
//
// Each account has its own image cache, so three members pulling postgres:16 store it three times. Ports
// below 1024 need an explicit capability grant. Both are acceptable for what this buys; neither is hidden.
/** Their own daemon's socket. The value `DOCKER_HOST` must point at. */
export const dockerSocketFor = (uid: number): string => `/run/user/${uid}/docker.sock`;
type Result = { ok: true; alreadyInstalled: boolean } | { ok: false; error: string };
async function sudo(args: string[]): Promise<{ ok: boolean; out: string }> {
const proc = Bun.spawn(['sudo', '-n', ...args], { stdout: 'pipe', stderr: 'pipe' });
const [out, err] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
return { ok: (await proc.exited) === 0, out: `${out}${err}`.trim() };
}
/**
* Run something as the member with a systemd user session in scope.
*
* `runAs` clears the environment, which is right everywhere else and fatal here: `systemctl --user` and the
* rootless setup tool locate the user manager through `XDG_RUNTIME_DIR` and `DBUS_SESSION_BUS_ADDRESS`. With
* those unset the tool reports "systemd not detected" and installs nothing, successfully.
*/
function asMemberWithSession(osUser: string, uid: number, command: string[]) {
const runtime = `/run/user/${uid}`;
return runAs(osUser, [
'env',
`XDG_RUNTIME_DIR=${runtime}`,
`DBUS_SESSION_BUS_ADDRESS=unix:path=${runtime}/bus`,
// The tool shells out to newuidmap, rootlesskit and dockerd, and --reset-env left PATH at the passwd
// default which does not include /usr/sbin.
'PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin',
...command,
]);
}
const text = async (proc: ReturnType<typeof runAs>) =>
`${await new Response(proc.stdout).text()}${await new Response(proc.stderr).text()}`.trim();
/** Everything that must be true of the HOST before any account can have rootless Docker. */
export function checkDockerPrerequisites(): { ok: true } | { ok: false; error: string } {
const missing: string[] = [];
if (!existsSync('/usr/bin/newuidmap') || !existsSync('/usr/bin/newgidmap')) missing.push('uidmap');
if (!existsSync('/usr/bin/dockerd-rootless-setuptool.sh')) missing.push('docker-ce-rootless-extras');
if (missing.length) {
return {
ok: false,
error: `rootless Docker needs these packages on the host: ${missing.join(', ')} (apt install ${missing.join(' ')})`,
};
}
return { ok: true };
}
/**
* Give the account its own rootless Docker daemon, and start it.
*
* Idempotent: the setup tool is safe to re-run, `enable-linger` on a lingering account is a no-op, and an
* already-running daemon is reported as `alreadyInstalled` rather than restarted a reprovision must not
* bounce a member's containers.
*
* Never throws. Docker is the least essential of the provisioning steps: without it the account still has a
* shell, a home and keys.
*/
export async function provisionRootlessDocker(params: {
osUser: string;
uid: number;
/**
* Their primary group. Separate from `uid` on purpose: it is the same number on a host where `useradd`
* allocates a per-user group, and it is NOT on one whose `login.defs` uses a shared group or on an account
* `ensureOsUser` adopted rather than created. Passing the uid in the group position is right until it
* quietly is not, and mode 700 hides the difference until somebody widens it for an unrelated reason.
*/
gid: number;
home: string;
}): Promise<Result> {
const prereq = checkDockerPrerequisites();
if (!prereq.ok) return prereq;
// Subordinate id range. `useradd` allocates one, so a missing entry means either an unusual login.defs or
// an account made by hand — worth naming rather than letting rootlesskit fail obscurely later.
const subuid = await sudo(['grep', '-q', `^${params.osUser}:`, '/etc/subuid']);
if (!subuid.ok) {
return {
ok: false,
error: `${params.osUser} has no /etc/subuid range, which rootless Docker requires. Add one with: sudo usermod --add-subuids 100000-165535 ${params.osUser}`,
};
}
// Linger FIRST: it is what creates /run/user/<uid> and starts the user manager, and everything below needs
// both to exist.
const linger = await sudo(['loginctl', 'enable-linger', params.osUser]);
if (!linger.ok) return { ok: false, error: `could not enable linger for ${params.osUser}: ${linger.out}` };
// The user manager appears asynchronously. Waiting beats a bare sleep, and the failure below is clearer
// than "systemd not detected" from the setup tool.
for (let attempt = 0; attempt < 25 && !existsSync(`/run/user/${params.uid}`); attempt++) {
await Bun.sleep(200);
}
if (!existsSync(`/run/user/${params.uid}`)) {
return {
ok: false,
error: `/run/user/${params.uid} never appeared, so ${params.osUser} has no systemd user session`,
};
}
const already = existsSync(dockerSocketFor(params.uid));
// Docker's storage, created by us and created CLEAN, before the daemon exists to create it dirty.
//
// The strip below used to be the whole story, guarded on the directory existing — which is false on a first
// run, because only the daemon creates it. So on a fresh account the strip no-opped, the daemon then made
// the directory itself and inherited the home's default ACLs, and the only cure was a retry: the very run
// that was supposed to clean it up was the one that created it. Verified on green's first provision, where
// it came out carrying `default:other::---` after a "successful" strip.
//
// A guard that depends on another process having got there first is a race however it is written, so the
// fix is ownership of the order: make it ourselves, with the member's uid and no defaults to inherit. Same
// shape as creating `~/.local` explicitly rather than letting `install -d` invent it as root.
const dockerStorage = `${params.home}/.local/share/docker`;
const madeStorage = await sudo([
'install',
'-d',
'-o',
String(params.uid),
'-g',
String(params.gid),
'-m',
'700',
dockerStorage,
]);
if (!madeStorage.ok) {
return { ok: false, error: `could not create ${dockerStorage}: ${madeStorage.out}` };
}
// ── The setup tool's exit code is deliberately NOT the gate ──
//
// It writes `~/.config/systemd/user/docker.service` and then runs `systemctl --user start docker.service`
// itself — which fails with "Unit docker.service not found" on a manager that was already running when the
// file appeared, because nothing reloaded it. Measured here: the unit was written correctly and the tool
// still exited 1.
//
// So: run it, reload, and start it ourselves. The tool's output is kept for the error message if the start
// then genuinely fails, since its diagnosis (a missing kernel module, an unsupported filesystem) is better
// than anything paraphrased.
const install = asMemberWithSession(params.osUser, params.uid, ['dockerd-rootless-setuptool.sh', 'install']);
const installOut = await text(install);
await install.exited;
const reload = asMemberWithSession(params.osUser, params.uid, ['systemctl', '--user', 'daemon-reload']);
await reload.exited;
const enable = asMemberWithSession(params.osUser, params.uid, ['systemctl', '--user', 'enable', '--now', 'docker']);
const enableOut = await text(enable);
if ((await enable.exited) !== 0) {
// Both halves, because the useful sentence is usually in the setup tool's output rather than systemd's.
const detail = [installOut, enableOut]
.map((s) => s.split('\n').slice(-3).join(' ').trim())
.filter(Boolean)
.join(' | ');
return { ok: false, error: `could not start ${params.osUser}'s Docker: ${detail}` };
}
// ── Undo our own ACLs, for this one subtree ──
//
// The home carries DEFAULT ACLs so the file browser can read a member's files (os-user.ts explains why).
// Docker inherits them under `~/.local/share/docker`, then fails every `docker run` with:
//
// failed to copy xattrs: failed to set xattr "system.posix_acl_default" on …/volumes/…/_data:
// invalid argument
//
// Creating a volume copies xattrs, and inside a rootless user namespace the mapped id in an inherited
// default ACL is not a valid id, so setting it is EINVAL. Two features built the same day, each correct
// alone. Measured: the image pulled fine — 403 MB into their home — and every container failed to start.
//
// `-k` removes DEFAULT entries only, so nothing inside Docker's storage inherits them from here on. The
// access ACLs on the home itself are untouched, which is what the file browser depends on. Losing the
// platform's reach into Docker's internal storage is no loss: it is image layers and volume data, read
// through `docker` or not at all.
// Unconditional now, and no longer the thing that has to win a race: the directory is ours from above, so
// this is repair for an account provisioned before that existed, and a no-op on a clean one. The guard it
// replaces could not tell "nothing to strip" from "nothing there yet", and answered the same way to both.
const stripped = await sudo(['setfacl', '-R', '-k', dockerStorage]);
if (!stripped.ok) {
return { ok: false, error: `could not clear inherited ACLs from ${dockerStorage}: ${stripped.out}` };
}
// Proof, not assumption: ask their daemon who it is. `docker version --format` on the SERVER half only
// answers if the socket is live and talking.
const verify = asMemberWithSession(params.osUser, params.uid, [
'env',
`DOCKER_HOST=unix://${dockerSocketFor(params.uid)}`,
'docker',
'version',
'--format',
'{{.Server.Version}}',
]);
const version = await text(verify);
if ((await verify.exited) !== 0) {
return {
ok: false,
error: `${params.osUser}'s Docker did not answer: ${version.split('\n').slice(-2).join(' ').trim()}`,
};
}
return { ok: true, alreadyInstalled: already };
}
+143
View File
@@ -0,0 +1,143 @@
import { readFile } from 'node:fs/promises';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { osUserHome } from './os-user';
// The shell a member gets when they open the terminal.
//
// ── Why there is a template at all ──
//
// A brand-new Linux account opens a shell with no prompt worth the name, no history, no completion and no
// colour — `useradd` copies /etc/skel, which on Ubuntu is a bash rc for a bash user. The account's shell is
// zsh, so it gets nothing. "Their own account" should not mean "a worse terminal than the owner's".
//
// ── What it is ──
//
// `shell-skel/zshrc` → `~/.zshrc`, and the platform's own `scripts/starship.toml` → `~/.config/starship.toml`
// so a member's prompt is the same one the owner's install deploys. That file is the single source for both:
// setup.sh copies it for the owner and this copies it for everybody else, so the two cannot drift.
//
// ── Never clobbering someone's edits ──
//
// Written only when the file is ABSENT. That makes this safe to re-run, which matters because the retry
// button reprovisions an account whenever the owner presses it, and losing somebody's shell configuration to
// a maintenance action would be indefensible.
//
// The cost is that improving a template reaches new accounts only. That is the right way round, and
// `~/.zshrc.local` — sourced last, never written — is the pressure valve: it is where your own configuration
// goes, so nothing a future template does can reach it.
/** Where the templates live, relative to this file. */
const SKEL_DIR = join(import.meta.dir, 'shell-skel');
/** The prompt config the owner's own install uses — one file, both audiences. */
const STARSHIP_SRC = join(import.meta.dir, '../../scripts/starship.toml');
type SudoResult = { ok: boolean; out: string };
async function sudo(args: string[]): Promise<SudoResult> {
const proc = Bun.spawn(['sudo', '-n', ...args], { stdout: 'pipe', stderr: 'pipe' });
const [out, err] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
return { ok: (await proc.exited) === 0, out: `${out}${err}`.trim() };
}
/** The file's current contents, or null when it does not exist. Read as root: the home is 700 and theirs. */
async function currentContents(path: string): Promise<string | null> {
const result = await sudo(['cat', path]);
return result.ok ? result.out : null;
}
/**
* Write one template file into the account's home, unless they have edited it.
*
* Returns what happened, so the caller can report "seeded" separately from "left alone" an owner pressing
* retry should not be told it rewrote files it deliberately did not touch.
*/
async function installTemplate(params: {
content: string;
dest: string;
uid: number;
gid: number;
}): Promise<{ ok: true; wrote: boolean } | { ok: false; error: string }> {
// Written only when ABSENT. Not "absent or identical to the template" — I wrote that first and it is
// meaningless: if the file already matches there is nothing to write, and if it differs we cannot tell an
// edit from an older template version, so the only safe reading of "differs" is "theirs". Updating a
// template therefore reaches new accounts only, which is the right trade for never eating someone's config.
if ((await currentContents(params.dest)) !== null) return { ok: true, wrote: false };
const dir = await mkdtemp(join(tmpdir(), 'officer-skel-'));
const staged = join(dir, 'staged');
try {
await writeFile(staged, params.content, { mode: 0o600 });
// The parent, explicitly and with the right owner. `install -D` creates missing parents but applies
// `-o`/`-g` only to the FILE — measured: it left `~/.config` as root:root, so the member could read their
// own starship.toml and could not write anything else into `.config`, which is where half of a shell's
// tools want to keep state. A single wrong-owner directory in a home is the kind of thing that surfaces
// weeks later as one tool mysteriously failing.
const parent = dirname(params.dest);
const madeParent = await sudo([
'install',
'-d',
'-o',
String(params.uid),
'-g',
String(params.gid),
'-m',
'700',
parent,
]);
if (!madeParent.ok) return { ok: false, error: `could not create ${parent}: ${madeParent.out}` };
const written = await sudo([
'install',
'-o',
String(params.uid),
'-g',
String(params.gid),
'-m',
'644',
staged,
params.dest,
]);
if (!written.ok) return { ok: false, error: `could not write ${params.dest}: ${written.out}` };
return { ok: true, wrote: true };
} finally {
await rm(dir, { recursive: true, force: true });
}
}
export type ShellSeedResult = { ok: true; wrote: string[]; kept: string[] } | { ok: false; error: string };
/**
* Give the account the standard shell configuration.
*
* Reports `wrote` and `kept` separately so a reprovision can say it left someone's edited files alone rather
* than implying it rewrote them.
*/
export async function seedShellConfig(params: { email: string; uid: number; gid: number }): Promise<ShellSeedResult> {
const home = osUserHome(params.email);
let zshrc: string;
let starship: string;
try {
zshrc = await readFile(join(SKEL_DIR, 'zshrc'), 'utf-8');
starship = await readFile(STARSHIP_SRC, 'utf-8');
} catch (ex) {
return { ok: false, error: `could not read the shell templates: ${ex instanceof Error ? ex.message : ex}` };
}
const wrote: string[] = [];
const kept: string[] = [];
for (const [content, dest] of [
[zshrc, join(home, '.zshrc')],
[starship, join(home, '.config/starship.toml')],
] as const) {
const result = await installTemplate({ content, dest, uid: params.uid, gid: params.gid });
if (!result.ok) return result;
(result.wrote ? wrote : kept).push(dest.replace(`${home}/`, '~/'));
}
return { ok: true, wrote, kept };
}
+57
View File
@@ -0,0 +1,57 @@
import { describe, expect, test } from 'bun:test';
import { validatePublicKey } from './os-user-ssh';
// `authorized_keys` is a file where every LINE is a credential, so the validation that matters is not
// "does this look like a key" — it is "is this exactly one".
describe('validatePublicKey', () => {
const ed25519 = 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHhTb2Rhb25lc3RyaW5nb2ZiYXNlNjRoZXJlMTIz ana@laptop';
test('accepts an ed25519 key with a comment', () => {
expect(validatePublicKey(ed25519)).toEqual({ ok: true, key: ed25519 });
});
test('accepts one with no comment, and trims surrounding whitespace', () => {
const bare = 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHhTb2Rhb25lc3RyaW5nb2ZiYXNlNjRoZXJlMTIz';
expect(validatePublicKey(` ${bare}\n`)).toEqual({ ok: true, key: bare });
});
test('accepts rsa and ecdsa', () => {
expect(validatePublicKey('ssh-rsa AAAAB3NzaC1yc2EAAAAsomethinglongenough== a@b').ok).toBe(true);
expect(validatePublicKey('ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTY= a@b').ok).toBe(true);
});
// THE case this function exists for. A second line is a second authorized key, and it would be granted
// silently — the caller only ever looks at whether the write succeeded.
test('refuses a second line, which would inject a second credential', () => {
const injected = `${ed25519}\nssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEludHJ1ZGVyc0tleUhlcmVQYWRkaW5nMTIz attacker@elsewhere`;
const result = validatePublicKey(injected);
expect(result.ok).toBe(false);
expect(result).toMatchObject({ error: expect.stringContaining('single line') });
});
test('refuses a carriage return too', () => {
expect(validatePublicKey(`${ed25519}\r\nssh-rsa AAAAB3Nz== x@y`).ok).toBe(false);
});
// Pasting the private half instead of the .pub is a genuine mistake, and it deserves a message that
// says so rather than "not a public key".
test('names the mistake when handed a private key', () => {
const priv = '-----BEGIN OPENSSH PRIVATE KEY-----';
expect(validatePublicKey(priv)).toMatchObject({ ok: false, error: expect.stringContaining('PRIVATE') });
});
test('refuses junk, an empty string and a bare algorithm name', () => {
expect(validatePublicKey('hello').ok).toBe(false);
expect(validatePublicKey('').ok).toBe(false);
expect(validatePublicKey('ssh-ed25519').ok).toBe(false);
expect(validatePublicKey('ssh-ed25519 not!valid!base64!').ok).toBe(false);
});
// An `authorized_keys` options prefix (`command="…" ssh-ed25519 …`) is a legitimate OpenSSH line but not
// something this form should accept: it can force a command or disable a restriction, and it is not what
// anyone pastes by accident.
test('refuses an options prefix', () => {
expect(validatePublicKey(`command="/bin/sh" ${ed25519}`).ok).toBe(false);
});
});
+171
View File
@@ -0,0 +1,171 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { osUserHome, runAs } from './os-user';
// SSH for a member's Linux account: how they reach the machine, and how the machine reaches Gitea as them.
//
// ── Two keys, two directions, and they are not alternatives ──
//
// inbound `authorized_keys` holds a public key the OWNER pasted at create time. Their private half
// stays on their laptop. Optional: an account with none is platform-only, which is a fine
// state, just not a shell-from-anywhere one.
// outbound `id_ed25519` is generated HERE, in their home, and never leaves. This is what pushes to
// Gitea.
//
// The distinction matters because "he pasted his key, so we can skip generating one" is the obvious
// simplification and it breaks the actual goal. Agent forwarding covers a human in an interactive SSH
// session; a platform-spawned agent has no agent socket to borrow, so an edge checkout it is asked to
// commit and push needs a key that lives on the box.
//
// ── Why every write goes through `sudo install` ──
//
// The home is 700 and owned by the member, so the service user cannot write into it at all — not even to
// create `.ssh`. `install` sets content, owner and mode in ONE step, which also closes the window where a
// key file exists at the process umask before a chmod lands. And passing file content as a path rather
// than as shell text means nothing here has to reason about quoting a value that came from a form.
/**
* Public key formats OpenSSH accepts, anchored and single-line.
*
* Validated because this string is appended to `authorized_keys`, where each line is a credential. A
* value with an embedded newline would inject a SECOND authorized key so the check that matters is not
* "does this look like a key" but "is this exactly one line".
*/
const PUBLIC_KEY_RE =
/^(ssh-ed25519|ssh-rsa|ecdsa-sha2-nistp256|ecdsa-sha2-nistp384|ecdsa-sha2-nistp521|sk-ssh-ed25519@openssh\.com|sk-ecdsa-sha2-nistp256@openssh\.com) [A-Za-z0-9+/]+={0,3}(\s+\S.*)?$/;
export function validatePublicKey(raw: string): { ok: true; key: string } | { ok: false; error: string } {
const key = raw.trim();
if (!key) return { ok: false, error: 'empty' };
// Checked before the pattern so the message is about the real problem: a pasted `id_ed25519` (private)
// or a multi-key blob are both things people actually do.
if (/[\r\n]/.test(key)) return { ok: false, error: 'a public key must be a single line' };
if (key.includes('PRIVATE KEY')) return { ok: false, error: 'that is a PRIVATE key — paste the .pub file' };
if (!PUBLIC_KEY_RE.test(key)) {
return { ok: false, error: 'not an OpenSSH public key (expected e.g. "ssh-ed25519 AAAA… comment")' };
}
return { ok: true, key };
}
type SudoResult = { ok: boolean; out: string };
async function sudo(args: string[]): Promise<SudoResult> {
const proc = Bun.spawn(['sudo', '-n', ...args], { stdout: 'pipe', stderr: 'pipe' });
const [out, err] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
return { ok: (await proc.exited) === 0, out: `${out}${err}`.trim() };
}
/** Write `content` into the member's tree with the right owner and mode, via a temp file. */
async function installFile(params: {
content: string;
dest: string;
uid: number;
gid: number;
mode: string;
}): Promise<SudoResult> {
const dir = await mkdtemp(join(tmpdir(), 'officer-ssh-'));
const staged = join(dir, 'staged');
try {
await writeFile(staged, params.content, { mode: 0o600 });
return await sudo([
'install',
'-o',
String(params.uid),
'-g',
String(params.gid),
'-m',
params.mode,
staged,
params.dest,
]);
} finally {
await rm(dir, { recursive: true, force: true });
}
}
export type SshProvisionResult =
| { ok: true; publicKey: string; generated: boolean; inboundKeyInstalled: boolean }
| { ok: false; error: string };
/**
* Give the account a working `~/.ssh`: inbound key if one was supplied, and an outbound keypair either way.
*
* Idempotent. An existing `id_ed25519` is kept and its public half returned rather than regenerated
* rotating a key silently would break every Gitea account and deploy key it had been added to.
*/
export async function provisionSshAccess(params: {
email: string;
osUser: string;
uid: number;
gid: number;
/** The owner-supplied public key for inbound SSH. Absent or empty means no inbound access. */
authorizedKey?: string | null;
}): Promise<SshProvisionResult> {
const home = osUserHome(params.email);
const sshDir = join(home, '.ssh');
const keyPath = join(sshDir, 'id_ed25519');
// `install -d` creates the directory with the owner and mode in one call. sshd refuses to use a .ssh
// that is group- or world-writable, so 700 is a requirement rather than caution.
const dir = await sudo(['install', '-d', '-o', String(params.uid), '-g', String(params.gid), '-m', '700', sshDir]);
if (!dir.ok) return { ok: false, error: `could not create ${sshDir}: ${dir.out}` };
let inboundKeyInstalled = false;
if (params.authorizedKey?.trim()) {
const checked = validatePublicKey(params.authorizedKey);
if (!checked.ok) return { ok: false, error: `public key rejected: ${checked.error}` };
const written = await installFile({
content: `${checked.key}\n`,
dest: join(sshDir, 'authorized_keys'),
uid: params.uid,
gid: params.gid,
mode: '600',
});
if (!written.ok) return { ok: false, error: `could not write authorized_keys: ${written.out}` };
inboundKeyInstalled = true;
}
// `accept-new` rather than seeding known_hosts with ssh-keyscan. We do not know the Gitea SSH host at
// account-creation time — the platform stores an HTTP base URL, and the SSH endpoint may be a different
// host or port entirely. The failure this prevents is specific and nasty: default StrictHostKeyChecking
// makes a first connection PROMPT, and a prompt in a non-interactive agent turn is a hang, not an error.
// `accept-new` trusts on first use and still refuses a CHANGED key, which is the attack that matters.
const config = await installFile({
content: ['Host *', ' StrictHostKeyChecking accept-new', ' IdentityFile ~/.ssh/id_ed25519', ''].join('\n'),
dest: join(sshDir, 'config'),
uid: params.uid,
gid: params.gid,
mode: '600',
});
if (!config.ok) return { ok: false, error: `could not write ssh config: ${config.out}` };
// Checked with sudo: the service user cannot stat inside a 700 home. It matters that this is checked
// rather than attempted — `ssh-keygen` on an existing path PROMPTS to overwrite, and that prompt in a
// spawned process is a hang.
const exists = await sudo(['test', '-f', keyPath]);
let generated = false;
if (!exists.ok) {
// Generated AS the member so the files are theirs from the moment they exist; a private key that is
// briefly root-owned is a private key that can be left root-owned by a failure halfway through.
const proc = runAs(params.osUser, [
'ssh-keygen',
'-t',
'ed25519',
'-N',
'',
'-C',
`${params.osUser}@officer`,
'-f',
keyPath,
]);
const err = await new Response(proc.stderr).text();
if ((await proc.exited) !== 0) return { ok: false, error: `ssh-keygen failed: ${err.trim()}` };
generated = true;
}
const pub = await sudo(['cat', `${keyPath}.pub`]);
if (!pub.ok) return { ok: false, error: `could not read the generated public key: ${pub.out}` };
return { ok: true, publicKey: pub.out.trim(), generated, inboundKeyInstalled };
}
+169
View File
@@ -0,0 +1,169 @@
import { describe, expect, test } from 'bun:test';
import { chmod, mkdtemp, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { findReadableSecrets, osUserNameFor, runAsArgv } from './os-user';
// The tests that matter here are the two that prove the MECHANISM rather than the plumbing: that Bun
// ignores `uid`, and that `setpriv` does not. Everything else in os-user.ts touches the passwd database
// and is exercised by hand — see docs/per-user-linux-accounts.md.
describe('Bun.spawn uid', () => {
// THE pin. `Bun.spawn` accepts `uid`/`gid` at RUNTIME and silently ignores them, so a privilege drop
// written the obvious way runs as the parent while looking correct. This test exists so that:
//
// - nobody "simplifies" runAs into a uid option, and
// - if Bun ever implements it, this fails and tells us we may.
//
// Bun's own types do not declare `uid`, so typed code cannot reach this by accident — hence the cast,
// which stands in for the ways you WOULD reach it: a spread of untyped config, or an `as any` in a
// hurry. The runtime is what silently accepts it, and the runtime is what this pins.
//
// Skipped when running as root, where setuid would actually be permitted and the observation changes.
test.skipIf(process.getuid?.() === 0)('is ignored at runtime — this is why runAs exists', async () => {
const nobody = 65534;
expect(process.getuid?.()).not.toBe(nobody);
const options = { uid: nobody, gid: nobody, stdout: 'pipe', stderr: 'pipe' } as unknown as {
stdout: 'pipe';
stderr: 'pipe';
};
const proc = Bun.spawn(['id', '-u'], options);
const seen = (await new Response(proc.stdout).text()).trim();
const code = await proc.exited;
// If this ever fails, read the assertion rather than fixing it: either the child ran as `nobody`
// (Bun now honours the option) or it refused with EPERM (Bun now tries). Both are good news.
expect(code).toBe(0);
expect(seen).toBe(String(process.getuid?.()));
expect(seen).not.toBe(String(nobody));
});
});
/** Whether this machine can actually drop privileges. See the sudo note in runAsArgv. */
const canSudo = await (async () => {
const proc = Bun.spawn(['sudo', '-n', 'true'], { stdout: 'ignore', stderr: 'ignore' });
return (await proc.exited) === 0;
})();
describe('runAsArgv', () => {
test('wraps the command in sudo + setpriv with real ids, groups and a reset environment', () => {
expect(runAsArgv('officer_ana', ['zsh', '-i'])).toEqual([
'sudo',
'-n',
'setpriv',
'--reuid=officer_ana',
'--regid=officer_ana',
'--init-groups',
'--reset-env',
'--',
'zsh',
'-i',
]);
});
// --init-groups and --reset-env are not decoration: without the first the process keeps the owner's
// supplementary groups, and without the second it inherits everything Bun loaded from .env.
test('never omits --init-groups or --reset-env', () => {
const argv = runAsArgv('officer_ana', ['true']);
expect(argv).toContain('--init-groups');
expect(argv).toContain('--reset-env');
});
test('refuses an empty user or command rather than running as the owner', () => {
expect(() => runAsArgv('', ['true'])).toThrow();
expect(() => runAsArgv('officer_ana', [])).toThrow();
});
// Proves the argv composes and runs end to end. Targets the CURRENT account so no test user has to be
// created, which still exercises sudo, setpriv, initgroups and the env reset.
//
// Skipped where passwordless sudo is unavailable — that is a machine that cannot run this feature at
// all, and a red test there would say "the code is broken" instead of "this host is not set up".
test.skipIf(!canSudo)('runs the command as the requested account', async () => {
const me = process.getuid?.() ?? 0;
const proc = Bun.spawn(runAsArgv(String(me), ['id', '-u']), { stdout: 'pipe', stderr: 'pipe' });
const out = (await new Response(proc.stdout).text()).trim();
const err = (await new Response(proc.stderr).text()).trim();
expect(await proc.exited, `setpriv failed: ${err}`).toBe(0);
expect(out).toBe(String(me));
});
// The property the whole feature rests on: the platform's environment does not cross the boundary. This
// process is started by PM2 in the platform directory, so Bun has auto-loaded `.env` into it — the JWT
// secret and POSTGRES_URL are in `process.env` right now. A member's shell must not see them.
test.skipIf(!canSudo)('does not pass the platform environment through', async () => {
const me = process.getuid?.() ?? 0;
const proc = Bun.spawn(runAsArgv(String(me), ['sh', '-c', 'echo "[${OFFICER_LEAK_PROBE:-unset}]"']), {
env: { ...process.env, OFFICER_LEAK_PROBE: 'this-must-not-cross' },
stdout: 'pipe',
stderr: 'pipe',
});
const out = (await new Response(proc.stdout).text()).trim();
expect(await proc.exited).toBe(0);
expect(out).toBe('[unset]');
});
// …and HOME is the target account's, not the caller's. This is what makes a member's shell and their
// agent's config land in their own directory rather than the owner's.
test.skipIf(!canSudo)('sets HOME from the target account, not the caller', async () => {
const me = process.getuid?.() ?? 0;
const proc = Bun.spawn(runAsArgv(String(me), ['sh', '-c', 'echo "$HOME"']), { stdout: 'pipe', stderr: 'pipe' });
const out = (await new Response(proc.stdout).text()).trim();
expect(await proc.exited).toBe(0);
expect(out).toBeTruthy();
// Read from passwd rather than inherited: `--reset-env` cleared the caller's HOME before setting it.
const passwd = Bun.spawn(['sh', '-c', `getent passwd ${me} | cut -d: -f6`], { stdout: 'pipe' });
expect(out).toBe((await new Response(passwd.stdout).text()).trim());
});
});
describe('osUserNameFor', () => {
// The chosen username, verbatim — so `whoami` in a member's terminal says who they are. Measured on this
// host: useradd accepts dots, hyphens, underscores and uppercase, i.e. everything validateUsername lets
// through.
test('uses the chosen username as-is', () => {
expect(osUserNameFor({ username: 'ana', email: 'ana@example.com' })).toBe('ana');
expect(osUserNameFor({ username: 'ana.silva', email: 'a@b.com' })).toBe('ana.silva');
expect(osUserNameFor({ username: 'Ana-Silva_2', email: 'a@b.com' })).toBe('Ana-Silva_2');
});
test('falls back to the email local part when there is no username', () => {
expect(osUserNameFor({ username: null, email: 'Ana.Silva@example.com' })).toBe('ana.silva');
expect(osUserNameFor({ username: ' ', email: 'Ana.Silva@example.com' })).toBe('ana.silva');
});
test('stays within the 32-character limit useradd enforces', () => {
expect(osUserNameFor({ username: 'a'.repeat(40), email: 'a@b.com' })).toHaveLength(32);
});
// No longer defended by a prefix, so it must be defended by adoption rules instead: `ensureOsUser`
// refuses a name whose existing passwd home is not the one we are about to confine, and refuses any uid
// below 1000 outright. This test records that the NAME itself is no longer the protection.
test('does not neutralise a dangerous name — that is ensureOsUser-s job now', () => {
expect(osUserNameFor({ username: 'root', email: 'r@b.com' })).toBe('root');
});
});
describe('findReadableSecrets', () => {
test('reports a group- or world-readable .env, and nothing when it is 600', async () => {
const dir = await mkdtemp(join(tmpdir(), 'officer-secrets-'));
const env = join(dir, '.env');
await writeFile(env, 'JWT_SECRET=x\n');
await chmod(env, 0o644);
expect(await findReadableSecrets(dir)).toEqual([env]);
// Group-only still counts: a member's supplementary groups are not ours to predict.
await chmod(env, 0o640);
expect(await findReadableSecrets(dir)).toEqual([env]);
await chmod(env, 0o600);
expect(await findReadableSecrets(dir)).toEqual([]);
});
test('an absent .env is not a finding', async () => {
const dir = await mkdtemp(join(tmpdir(), 'officer-secrets-'));
expect(await findReadableSecrets(dir)).toEqual([]);
});
});
+515
View File
@@ -0,0 +1,515 @@
import { chmod, mkdir, readdir, stat } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { DATA_PATH, USER_DIRS, toShellUsername } from './data-path';
// Real Linux accounts for members, so the surfaces that execute code can run as them.
//
// Design, prerequisites and the staging plan: docs/per-user-linux-accounts.md. Read it before changing
// anything here — several of the choices below look arbitrary and are not.
//
// ── The one thing to know ──
//
// `Bun.spawn` SILENTLY IGNORES `uid` and `gid`. Verified on bun 1.3.10: from uid 1000,
// `Bun.spawn(['id','-u'], { uid: 65534 })` exits 0 and prints 1000. No throw, no warning. So a privilege
// drop written the obvious way would look like it worked while every member's process ran as the owner —
// an isolation boundary that is silently absent, which is worse than none at all because it is believed.
//
// Everything here goes through `setpriv`. os-user.test.ts pins Bun's behaviour so that if it is ever
// fixed, a test tells us we may simplify, rather than someone assuming it and being wrong.
/** Off unless explicitly enabled: this needs root, and a light install has no sudoers entry. */
export const OS_USERS_ENABLED = process.env.OFFICER_OS_USERS === 'true' || process.env.OFFICER_OS_USERS === '1';
const MAX_USERNAME = 32;
/**
* The Linux account name for a platform account: **the username the owner chose**, verbatim.
*
* This carried an `officer_` prefix until 2026-08-11. The prefix bought two things no collision with a
* system account, and a greppable record of what we created and cost the thing the owner actually wants,
* which is that `whoami` in a member's terminal says who they are. Measured before removing it: `useradd`
* on this host accepts everything `validateUsername` permits, including dots, hyphens, underscores and
* uppercase.
*
* What replaced the prefix's safety is a stricter ADOPTION rule in `ensureOsUser`: an existing Linux account
* is only reused when its passwd home is already the home we expect. Without that, a platform account named
* `root` would have adopted root. See there.
*
* `toShellUsername` still handles the fallback when there is no username, since the email local part can
* contain things a Linux name cannot.
*/
export function osUserNameFor(params: { username: string | null; email: string }): string {
const chosen = params.username?.trim();
if (chosen) return chosen.slice(0, MAX_USERNAME);
return toShellUsername('', params.email).slice(0, MAX_USERNAME);
}
/**
* The login shell a new account gets: zsh where it exists, bash otherwise.
*
* Resolved from `/etc/shells`-style existence rather than from this process's environment. See the call site.
*/
async function defaultShell(): Promise<string> {
for (const candidate of ['/usr/bin/zsh', '/bin/zsh', '/bin/bash']) {
if (existsSync(candidate)) return candidate;
}
return '/bin/sh';
}
/** The account's home as passwd records it, or null if it has none / does not exist. */
async function passwdHome(osUser: string): Promise<string | null> {
const result = await run(['getent', 'passwd', osUser]);
if (!result.ok) return null;
return result.out.split(':')[5] ?? null;
}
export type RunAsOptions = {
/** Passed through to the wrapped command. `setpriv --reset-env` means nothing else survives. */
env?: Record<string, string>;
cwd?: string;
};
/**
* The argv that runs `command` as `osUser`. Pure, so the shape is testable without spawning anything.
*
* sudo -n REQUIRED, and not merely for the uid. Measured 2026-08-11: `--init-groups` fails
* with "initgroups failed: Operation not permitted" for an unprivileged caller even
* when reuid'ing to its OWN account setgroups(2) is root-only, full stop. So this
* cannot be done without privilege, and `-n` makes a missing sudoers entry an
* immediate error instead of a process blocking on a password prompt nobody will see.
* --reuid/--regid the REAL ids, not merely effective there is nothing to switch back to.
* --init-groups apply the account's supplementary groups. Without it the process keeps the OWNER'S
* groups, which quietly retains access we just took away.
* --reset-env drop the inherited environment, then set HOME/SHELL/USER/LOGNAME/PATH from the
* target's passwd entry. Both halves matter: the parent's env carries the owner's HOME
* and in a PM2 process started in the platform directory everything Bun auto-loaded
* from `.env`. Verified: `POSTGRES_URL` is unset on the far side, and HOME arrives as
* the member's own.
*
* `sudo -u <user>` alone would also work and would be shorter. It is not used because its environment
* handling is sudoers policy (`env_reset`, `env_keep`, `always_set_home`) rather than something this file
* states and "which variables cross into a member's shell" is exactly the question that must not depend
* on a config file somebody may have edited.
*/
export function runAsArgv(osUser: string, command: string[]): string[] {
if (!osUser) throw new Error('runAsArgv: no OS user');
if (!command.length) throw new Error('runAsArgv: empty command');
return [
'sudo',
'-n',
'setpriv',
`--reuid=${osUser}`,
`--regid=${osUser}`,
'--init-groups',
'--reset-env',
'--',
...command,
];
}
/**
* Run a command as another Linux account.
*
* Deliberately does NOT accept a `uid` option. The only supported way to change user in this codebase is
* this function, precisely because the option that looks like it would work does nothing.
*/
export function runAs(osUser: string, command: string[], options: RunAsOptions = {}) {
return Bun.spawn(runAsArgv(osUser, command), {
cwd: options.cwd,
// Reaches sudo and setpriv, NOT the command — `--reset-env` clears it on the way through. Anything
// the command needs beyond the passwd-derived HOME/SHELL/USER/LOGNAME/PATH has to be stated inside
// `command` itself (`env FOO=bar cmd …`). That asymmetry is deliberate: it means a variable can only
// cross into a member's process because someone wrote it there.
env: options.env,
stdout: 'pipe',
stderr: 'pipe',
});
}
async function run(command: string[]): Promise<{ ok: boolean; out: string }> {
const proc = Bun.spawn(command, { stdout: 'pipe', stderr: 'pipe' });
const [out, err] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
const code = await proc.exited;
return { ok: code === 0, out: `${out}${err}`.trim() };
}
/** uid/gid from the passwd database, or null when the account does not exist. */
export async function lookupOsUser(osUser: string): Promise<{ uid: number; gid: number } | null> {
const uid = await run(['id', '-u', osUser]);
if (!uid.ok) return null;
const gid = await run(['id', '-g', osUser]);
if (!gid.ok) return null;
return { uid: Number(uid.out), gid: Number(gid.out) };
}
/** The member's home: `DATA_PATH/<email>/home`, where `provisionUserDirs` already put it. */
export const osUserHome = (email: string): string => join(DATA_PATH, email, 'home');
/**
* The first directory on the way to `path` that `osUser` cannot traverse, or null if the whole chain is fine.
*
* Exists because of a real and thoroughly confusing failure. A member's home is under `DATA_PATH`, which on
* a normal install is under the OWNER'S home and `/home/<owner>` is 750 on Debian and Ubuntu. So every
* mode bit we set on the account tree was correct, the directory existed, and the member still could not
* reach it, because they had no `x` on an ancestor four levels up. What surfaced was
* `ssh-keygen: Could not stat …/.ssh: Permission denied`, which points at exactly the wrong place.
*
* `x` without `r` is the ask: traversal, not listing. Nobody gains the ability to enumerate the owner's home.
*/
export async function firstUntraversableAncestor(osUser: string, path: string): Promise<string | null> {
const parts = path.split('/').filter(Boolean);
const chain = parts.map((_, i) => `/${parts.slice(0, i + 1).join('/')}`);
// One spawn rather than one per level: this runs on every account creation, and the chain is ~6 deep.
const proc = runAs(osUser, [
'sh',
'-c',
'for p in "$@"; do [ -x "$p" ] || { printf %s "$p"; exit 0; }; done',
'sh',
...chain,
]);
const out = (await new Response(proc.stdout).text()).trim();
await proc.exited;
return out || null;
}
export type EnsureOsUserResult =
| { ok: true; osUser: string; uid: number; gid: number; created: boolean }
| { ok: false; error: string };
/**
* Create the Linux account if it does not exist, then place the ownership and mode bits.
*
* Idempotent in both halves: an existing account is adopted rather than recreated, and the modes are
* re-applied every time, so a directory the platform added later is confined without needing a
* migration.
*
* Never throws. Account creation is a side effect of creating a platform account, and a `useradd` that
* failed must not leave a half-made user behind the caller records the error and the platform account
* simply has no OS account yet.
*/
export async function ensureOsUser(params: { email: string; username: string | null }): Promise<EnsureOsUserResult> {
const osUser = osUserNameFor(params);
const home = osUserHome(params.email);
let created = false;
let ids = await lookupOsUser(osUser);
// ── Adoption, and why it is this strict ──
//
// An account that already exists is REUSED, which is what makes this function re-runnable. While names
// carried an `officer_` prefix that was safe by construction: nothing else creates those. Now that the
// name is whatever the owner typed, adoption is the dangerous path — a platform account named `root`
// would find root in passwd, and every `runAs` for that member would then be a root shell.
//
// The test is the account's own home. If passwd already points it at exactly the directory we are about
// to confine, it is ours (or a previous run's). Anything else is somebody else's account that happens to
// share a name, and the answer is to refuse rather than to touch it.
//
// The uid floor is belt and braces: a system account below 1000 could in principle be created with a
// matching home, and none of them should ever be handed to a member.
if (ids) {
const existingHome = await passwdHome(osUser);
if (ids.uid < 1000) {
return { ok: false, error: `'${osUser}' is a system account on this machine. Choose another username.` };
}
if (existingHome !== home) {
return {
ok: false,
error:
`'${osUser}' is already a user on this machine, with its home at ${existingHome ?? 'an unknown path'}. ` +
`Refusing to take it over — choose another username.`,
};
}
}
if (!ids) {
// `-M` because provisionUserDirs already made the directory, and letting useradd create it would copy
// /etc/skel in as root-owned.
//
// The shell is chosen from what is INSTALLED, not from `process.env.SHELL`. That was the first version and
// it is wrong twice: this process is started by PM2, whose environment has whatever shell PM2 was launched
// from — often `/bin/sh` and sometimes nothing — so the member's shell depended on how the server happened
// to be started. zsh is what the platform's own setup installs and what the shell template targets.
const create = await run([
'sudo',
'-n',
'useradd',
'--home-dir',
home,
'-M',
'--shell',
await defaultShell(),
osUser,
]);
if (!create.ok) return { ok: false, error: `useradd failed: ${create.out}` };
created = true;
ids = await lookupOsUser(osUser);
if (!ids) return { ok: false, error: `useradd reported success but ${osUser} is not in passwd` };
}
const confined = await confineUserTree({ email: params.email, uid: ids.uid, gid: ids.gid });
if (!confined.ok) return { ok: false, error: confined.error };
// Checked before anything tries to USE the home, so the error names the actual problem. Every mode bit on
// the account tree can be right while the member still cannot get there, because `DATA_PATH` normally
// lives under the owner's home and `/home/<owner>` is 750 on Debian and Ubuntu.
const blocked = await firstUntraversableAncestor(osUser, home);
if (blocked) {
return {
ok: false,
error:
`${osUser} cannot traverse into ${blocked}, so it cannot reach its own home. ` +
`Fix with: chmod o+x ${blocked} ` +
`(that grants traversal only — the directory stays unlistable.)`,
};
}
// A new home is EMPTY, deliberately. This used to create Downloads/Documents/Music/Videos/Pictures — a
// habit inherited from the file browser, which did the same lazily for the owner. Nothing needs them:
// guessing at somebody's folder layout is a decision the platform has no standing to make, and an empty
// home is honest about being new.
return { ok: true, osUser, uid: ids.uid, gid: ids.gid, created };
}
/**
* Place the mode bits described in docs/per-user-linux-accounts.md § "The layout".
*
* DATA_PATH 711 service user traverse only a member cannot enumerate the members
* DATA_PATH/<email> 711 service user traverse only a member cannot list their OWN siblings
* /home 700 the member their home
* every sibling 700 service user platform-written, unreachable even by name
*
* 711 on the account directory is the load-bearing one. The member needs `x` to reach `home`, and must
* not have `r`, or `ls` would show them the platform's private tree beside it. Because every sibling is
* 700 and owned by the service user, guessing a name gains nothing either.
*
* `chown` on the home is done with sudo: the service user owns the directory but cannot give it away
* `chown` to another user is a root-only operation on Linux regardless of who owns the file.
*/
export async function confineUserTree(params: {
email: string;
uid: number;
gid: number;
}): Promise<{ ok: true } | { ok: false; error: string }> {
const accountDir = join(DATA_PATH, params.email);
const home = join(accountDir, 'home');
try {
if (!existsSync(home)) await mkdir(home, { recursive: true });
// Traversable, not listable. Applied to DATA_PATH itself too: without it a member can read the
// directory and learn every other member's email address.
await chmod(DATA_PATH, 0o711);
await chmod(accountDir, 0o711);
// Every sibling of `home` is the platform's. 700 means traversal alone does not open them.
const entries = await readdir(accountDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.name === 'home') continue;
if (!entry.isDirectory()) continue;
await chmod(join(accountDir, entry.name), 0o700);
}
// And any of the standard set that does not exist yet, so a directory created later starts confined
// rather than at the process umask.
for (const dir of USER_DIRS) {
if (dir === 'home') continue;
const path = join(accountDir, dir);
if (!existsSync(path)) await mkdir(path, { recursive: true, mode: 0o700 });
}
// The home goes through sudo for BOTH operations, and that is the only form that is idempotent.
// `chmod` requires ownership, so:
// - chmod then chown, unprivileged: works once, then fails EPERM forever after, because the home now
// belongs to the member. Re-running an install would report failure on a correct tree.
// - chown then chmod, unprivileged: fails immediately, for the same reason.
// Both were observed. Root does not care about either ordering, so both go through sudo and the
// function can be run any number of times.
const give = await run(['sudo', '-n', 'chown', '-R', `${params.uid}:${params.gid}`, home]);
if (!give.ok) return { ok: false, error: `chown of ${home} failed: ${give.out}` };
const close = await run(['sudo', '-n', 'chmod', '700', home]);
if (!close.ok) return { ok: false, error: `chmod of ${home} failed: ${close.out}` };
// ── And then let the PLATFORM in, by ACL ──
//
// A 700 home owned by the member locks out the service user, which is correct for a shell and fatal for
// the file browser: it runs inside the platform process, so `readdir` returned EACCES and `/ls` reported
// "This folder is empty" over five directories that were sitting right there. Observed 2026-08-11.
//
// These are two different doors and they need different boundaries. The terminal and the agent RUN AS the
// member, and there the kernel is the boundary. The file browser acts on the member's behalf from inside
// the platform, which already applies its own containment (`resolveUserPath`) and which is the owner's
// process on the owner's machine — it can read anything via sudo regardless. Giving it access is not a
// hole, it is the honest description of who is doing the work.
//
// Why ACLs and not mode bits or a group. It has to work in BOTH directions: a file the platform writes
// must be editable by the member, and a file the member writes must be editable by the platform. Mode
// bits cannot express that — whichever of the two is neither owner nor group ends up as "other", and
// widening "other" would open the home to every account on the box. A shared group fails the same way
// once you notice both parties would have to be in it, which would put every member in a group that can
// read every other member's home. Named ACL entries grant exactly two users, and the `d:` defaults are
// inherited by everything created afterwards, by either party, whatever their umask.
const serviceUid = process.getuid?.();
if (serviceUid !== undefined) {
const entries = [
`u:${serviceUid}:rwx`,
`u:${params.uid}:rwx`,
`d:u:${serviceUid}:rwx`,
`d:u:${params.uid}:rwx`,
].flatMap((entry) => ['-m', entry]);
// After the chmod, never before: chmod recomputes the ACL mask and would clamp entries set earlier.
const acl = await run(['sudo', '-n', 'setfacl', '-R', ...entries, home]);
if (!acl.ok) {
return {
ok: false,
error:
`could not set access control lists on ${home}: ${acl.out}. ` +
`The file browser cannot read a member's home without them. ` +
`Install the acl package (apt install acl) and retry.`,
};
}
}
// ── One directory where container bind mounts can live ──
//
// The default ACLs above are inherited by everything created in the home afterwards, including
// `default:other::---`. A rootless container's INNER uid is neither the service user nor the member —
// postgres:18-alpine runs as uid 70, which maps through the member's subuid range to 231141 — so it is
// `other`, and `other` has no `x`. It cannot traverse a directory it otherwise owns.
//
// `3bea46f` stripped defaults from `~/.local/share/docker` and concluded the problem solved. That fixed
// NAMED VOLUMES only. A bind mount lives wherever the member put it, and there it hits the same denial by
// a different route — reported from a real server as `mkdir: can't create directory '…/18/docker'` on a
// directory that already existed. A named volume passes with this bug present, which is exactly how the
// first fix looked complete.
//
// Three ways to fix it, and this is the third:
//
// - extend the strip to wherever the bind source is → unbounded, the member chooses the path
// - `d:other::--x` on the whole home → traverse for every uid, forever, to fix one local case
// - bless ONE directory → scoped, predictable, and already where members work
//
// The cost is that the file browser cannot read inside it, which is the same trade already accepted for
// Docker's internal storage — consistent rather than a new exception. Not enforced: a member can bind
// mount from anywhere and will hit the denial there. This is the documented place that works.
// `.local` FIRST, explicitly, with the member's ownership. `install -d` creates missing parents but
// applies `-o`/`-g`/`-m` only to the FINAL component, so letting it invent `.local` leaves that directory
// root:root — inside the member's own home, unwritable by them.
//
// This is `71589ae` for the second time. That commit found the identical thing for `~/.config` and wrote
// "a single wrong-owner directory in a home is the kind of thing that surfaces weeks later as one tool
// mysteriously failing". It surfaced in twenty minutes: rootless Docker died on
// `mkdir …/.local/share: permission denied`, and the Claude installer targets `~/.local/bin`, so it was
// blocked by the same directory. Grep before adding another `install -d`/`-D` whose parent is implicit.
const localDir = join(home, '.local');
const madeLocalDir = await run([
'sudo',
'-n',
'install',
'-d',
'-o',
String(params.uid),
'-g',
String(params.gid),
'-m',
'700',
localDir,
]);
if (!madeLocalDir.ok) return { ok: false, error: `could not create ${localDir}: ${madeLocalDir.out}` };
const composeDir = join(home, '.local', 'dockers');
const madeComposeDir = await run([
'sudo',
'-n',
'install',
'-d',
'-o',
String(params.uid),
'-g',
String(params.gid),
// 711, not 700, and this is the whole point of the directory. A container's inner uid is `other`, so it
// needs `x` HERE to reach a bind source inside — 700 blocks the path before any ACL matters. `r` stays
// off, so nothing can list it. Safe because the home above is 700: no other account can traverse this
// far in the first place, and the only uids that get here are the member's own containers.
'-m',
'711',
composeDir,
]);
if (!madeComposeDir.ok) return { ok: false, error: `could not create ${composeDir}: ${madeComposeDir.out}` };
// `-b`, not `-k`: remove ACCESS entries as well as defaults, leaving plain POSIX modes.
//
// `-k` alone left `mask::---` behind — inherited named entries with every permission masked off, reading
// as `user:pastilhas:rwx #effective:---`. An ACL that says one thing and means another is worse than no
// ACL, and container storage is the one place in the home that wants ordinary mode bits and nothing else.
//
// AFTER the recursive grant above, or what it just set is re-inherited here.
const stripped = await run(['sudo', '-n', 'setfacl', '-R', '-b', composeDir]);
if (!stripped.ok) {
return { ok: false, error: `could not clear inherited ACLs from ${composeDir}: ${stripped.out}` };
}
return { ok: true };
} catch (ex) {
return { ok: false, error: ex instanceof Error ? ex.message : String(ex) };
}
}
/**
* Refuse to enable OS users while a secret in the project tree is readable by them.
*
* `platform/.env` was 664 on this machine when this was written world-readable, holding the JWT signing
* secret and `POSTGRES_URL`. A member with a shell could read it and mint an owner token, which would
* leave the capability model intact and entirely bypassed.
*
* Checked at boot rather than documented, because a prerequisite that is only written down is one that
* gets skipped. Returns the offending paths; the caller decides whether that is fatal.
*/
export async function findReadableSecrets(projectDir: string): Promise<string[]> {
const candidates = ['.env', '.env.local', '.env.production'];
const bad: string[] = [];
for (const name of candidates) {
const path = join(projectDir, name);
if (!existsSync(path)) continue;
try {
const info = await stat(path);
// Anything readable by group or other. 0o044 covers both read bits.
if (info.mode & 0o044) bad.push(path);
} catch {
// Unreadable to us is not a leak to them; nothing to report.
}
}
return bad;
}
/**
* Refuse to boot with OS users enabled while a secret in the project tree is readable by them.
*
* Same posture as `assertCapabilityTotality`, and for the same reason: this is a prerequisite that
* silently not holding would make the whole feature theatre. Confirmed exploitable while testing a
* member's shell read `platform/.env` and printed `JWT_SECRET`, which is enough to mint an owner token and
* bypass every capability check in the codebase.
*
* A no-op when the feature is off, so an existing install is unaffected until the owner opts in.
*/
export async function assertSecretsClosed(projectDir: string): Promise<void> {
if (!OS_USERS_ENABLED) return;
const readable = await findReadableSecrets(projectDir);
if (!readable.length) return;
throw new Error(
[
'OFFICER_OS_USERS is enabled, but these files are readable by other accounts on this machine:',
'',
...readable.map((p) => `${p}`),
'',
'A member with a shell can read them. JWT_SECRET alone is enough to mint an owner token, which',
'bypasses every capability check. Fix with:',
'',
...readable.map((p) => ` chmod 600 ${p}`),
'',
'Then restart. See docs/per-user-linux-accounts.md → "Hard prerequisite".',
].join('\n'),
);
}
+126
View File
@@ -0,0 +1,126 @@
# Officer — default shell configuration.
#
# Written when your Linux account was created. It is yours: edit it freely. Officer only ever writes this
# file if it is missing or still byte-for-byte identical to the template, so your changes survive every
# reprovision.
#
# Deliberately depends on nothing but zsh. Starship, eza, nvim and bun are each used only if present, so
# this same file works on a minimal server and on a fully equipped one.
# ── PATH ──
# $HOME-relative throughout. Anything hard-coded to one person's home is a template that only works for
# them, which is how the owner's own .zshrc grew three absolute paths.
export PATH="$HOME/bin:$HOME/.local/bin:/usr/local/bin:$PATH"
[ -d "$HOME/.bun/bin" ] && export PATH="$HOME/.bun/bin:$PATH"
[ -d "$HOME/.deno/bin" ] && export PATH="$HOME/.deno/bin:$PATH"
[ -d "$HOME/.cargo/bin" ] && export PATH="$HOME/.cargo/bin:$PATH"
[ -d "$HOME/.opencode/bin" ] && export PATH="$HOME/.opencode/bin:$PATH"
[ -d /opt/nvim-linux-x86_64/bin ] && export PATH="$PATH:/opt/nvim-linux-x86_64/bin"
# ── History ──
# The bits oh-my-zsh would otherwise be pulled in to provide. Shared across concurrent shells, which
# matters here: a browser tab and an SSH session are often the same person in the same directory.
HISTFILE="$HOME/.zsh_history"
HISTSIZE=50000
SAVEHIST=50000
setopt SHARE_HISTORY # write and read as you go, not only at exit
setopt HIST_IGNORE_ALL_DUPS # keep one copy of a repeated command
setopt HIST_IGNORE_SPACE # a leading space keeps it out of history
setopt HIST_REDUCE_BLANKS
setopt EXTENDED_HISTORY # timestamps
# ── Directories and globbing ──
setopt AUTO_CD # `..` and bare directory names change directory
setopt AUTO_PUSHD # every cd pushes, so `cd -<TAB>` is a menu
setopt PUSHD_IGNORE_DUPS
setopt EXTENDED_GLOB
setopt INTERACTIVE_COMMENTS # `#` works when pasting a commented command
# ── Completion ──
autoload -Uz compinit
# Cache the dump in the account's own home; -C skips the security check on a dump written today, which is
# the difference between an instant prompt and a visible pause on every new shell.
compinit -d "$HOME/.zcompdump"
zstyle ':completion:*' menu select
zstyle ':completion:*' matcher-list 'm:{a-zA-Z}={A-Za-z}' # case-insensitive
zstyle ':completion:*' list-colors ''
setopt COMPLETE_IN_WORD
setopt ALWAYS_TO_END
# ── Keys ──
# Emacs bindings explicitly: with EDITOR=nvim zsh would otherwise pick vi mode, which surprises anyone who
# did not ask for it.
bindkey -e
autoload -Uz up-line-or-beginning-search down-line-or-beginning-search
zle -N up-line-or-beginning-search
zle -N down-line-or-beginning-search
bindkey '^[[A' up-line-or-beginning-search # Up: history matching what is already typed
bindkey '^[[B' down-line-or-beginning-search
bindkey '^[[1;5C' forward-word # ctrl-arrow by word
bindkey '^[[1;5D' backward-word
bindkey '^[[3~' delete-char
bindkey '^[[H' beginning-of-line
bindkey '^[[F' end-of-line
# ── Editor ──
if command -v nvim >/dev/null 2>&1; then
export EDITOR=nvim VISUAL=nvim SUDO_EDITOR=nvim
alias n='nvim'
alias vim='nvim'
elif command -v vim >/dev/null 2>&1; then
export EDITOR=vim VISUAL=vim
fi
# ── Aliases ──
if command -v eza >/dev/null 2>&1; then
alias ls='eza --group-directories-first'
alias ll='eza -l --group-directories-first --git'
alias la='eza -la --group-directories-first --git'
alias lt='eza --tree --level=2'
else
alias ls='ls --color=auto --group-directories-first'
alias ll='ls -lh'
alias la='ls -lah'
fi
alias grep='grep --color=auto'
alias ..='cd ..'
alias ...='cd ../..'
alias sz='source "$HOME/.zshrc"'
command -v duf >/dev/null 2>&1 && alias duf='duf --only local'
command -v lazygit >/dev/null 2>&1 && alias lg='lazygit'
# ── Prompt ──
# Starship if it is installed; zsh's own prompt with the same information if not. The fallback exists
# because a shell that opens with a broken prompt reads as a broken machine, and a minimal install has
# every right not to have starship on it.
if command -v starship >/dev/null 2>&1; then
eval "$(starship init zsh)"
else
autoload -Uz vcs_info
precmd_vcs_info() { vcs_info }
precmd_functions+=( precmd_vcs_info )
zstyle ':vcs_info:git:*' formats ' %F{blue}%b%f'
setopt PROMPT_SUBST
PROMPT='%F{green}%n%f@%F{yellow}%m%f:[%~${vcs_info_msg_0_}]
%F{blue}%f '
fi
# ── Docker ──
# Your own rootless daemon, if Officer provisioned one. Containers you start run as your account in your own
# user namespace — root inside them is you outside them, and you cannot see anyone else's containers.
#
# Set from $XDG_RUNTIME_DIR rather than a hard-coded uid so this line is the same in every account's file.
if [ -S "${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/docker.sock" ]; then
export DOCKER_HOST="unix://${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/docker.sock"
fi
# ── Your own additions ──
# Sourced last so anything here wins. Officer never writes to it, so it is the safe place for your own
# configuration — and it is why this file can be replaced by a newer template without losing your work.
[ -f "$HOME/.zshrc.local" ] && source "$HOME/.zshrc.local"
# Tool-installed completions and env, if they exist. Each of these appends itself to a shell rc when you
# install the tool; sourcing them conditionally keeps that working without hard-coding anyone's home.
[ -s "$HOME/.bun/_bun" ] && source "$HOME/.bun/_bun"
[ -s "$HOME/.deno/env" ] && source "$HOME/.deno/env"
[ -f "$HOME/.cargo/env" ] && source "$HOME/.cargo/env"
+12 -12
View File
@@ -288,13 +288,13 @@ export async function spawnClaudeStreaming(params: ClaudeSpawnStreamingParams):
throw new Error('Unexpected response'); throw new Error('Unexpected response');
} }
export function killClaude(sessionKey: string): void { export function killClaude(sessionKey: string, userId: number): void {
sendFire('claude', { type: 'claude:kill', id: nextId(), sessionKey }); sendFire('claude', { type: 'claude:kill', id: nextId(), sessionKey, userId });
} }
// Interrupt the current turn but keep the persistent session warm (the "stop" button). // Interrupt the current turn but keep the persistent session warm (the "stop" button).
export function interruptClaude(sessionKey: string): void { export function interruptClaude(sessionKey: string, userId: number): void {
sendFire('claude', { type: 'claude:interrupt', id: nextId(), sessionKey }); sendFire('claude', { type: 'claude:interrupt', id: nextId(), sessionKey, userId });
} }
/** /**
@@ -323,11 +323,11 @@ export function interruptClaude(sessionKey: string): void {
* deliberate: there, not knowing means leaving a spinner up; here, not knowing would mean inventing * deliberate: there, not knowing means leaving a spinner up; here, not knowing would mean inventing
* sessions, and an enumeration that reports things that may not exist is worse than a short one. * sessions, and an enumeration that reports things that may not exist is worse than a short one.
*/ */
export async function listLiveClaudeSessions(): Promise<LiveClaudeSession[]> { export async function listLiveClaudeSessions(userId: number): Promise<LiveClaudeSession[]> {
const sc = findSidecarByCapability('claude'); const sc = findSidecarByCapability('claude');
if (!sc) return []; if (!sc) return [];
try { try {
const res = await sendCommandToSidecar(sc, { type: 'claude:list', id: nextId() }); const res = await sendCommandToSidecar(sc, { type: 'claude:list', id: nextId(), userId });
return res.type === 'claude:sessions' ? res.sessions : []; return res.type === 'claude:sessions' ? res.sessions : [];
} catch { } catch {
return []; return [];
@@ -352,11 +352,11 @@ export async function listLiveOpenCodeSessions(): Promise<LiveOpenCodeSession[]>
} }
} }
export async function isClaudeGenerating(sessionKey: string): Promise<boolean> { export async function isClaudeGenerating(sessionKey: string, userId: number): Promise<boolean> {
const sc = findSidecarByCapability('claude'); const sc = findSidecarByCapability('claude');
if (!sc) return false; if (!sc) return false;
try { try {
const res = await sendCommandToSidecar(sc, { type: 'claude:is-generating', id: nextId(), sessionKey }); const res = await sendCommandToSidecar(sc, { type: 'claude:is-generating', id: nextId(), sessionKey, userId });
return res.type === 'claude:generating' ? res.generating : true; return res.type === 'claude:generating' ? res.generating : true;
} catch { } catch {
return true; return true;
@@ -373,19 +373,19 @@ export async function isClaudeGenerating(sessionKey: string): Promise<boolean> {
* Fails toward null: no agent, no answer, or a timeout all mean "cannot re-bind", and the caller falls * Fails toward null: no agent, no answer, or a timeout all mean "cannot re-bind", and the caller falls
* back to today's behaviour of leaving the socket unattached rather than binding it to a guess. * back to today's behaviour of leaving the socket unattached rather than binding it to a guess.
*/ */
export async function findClaudeSessionKey(claudeSessionId: string): Promise<string | null> { export async function findClaudeSessionKey(claudeSessionId: string, userId: number): Promise<string | null> {
const sc = findSidecarByCapability('claude'); const sc = findSidecarByCapability('claude');
if (!sc) return null; if (!sc) return null;
try { try {
const res = await sendCommandToSidecar(sc, { type: 'claude:find-session', id: nextId(), claudeSessionId }); const res = await sendCommandToSidecar(sc, { type: 'claude:find-session', id: nextId(), claudeSessionId, userId });
return res.type === 'claude:session-key' ? res.sessionKey : null; return res.type === 'claude:session-key' ? res.sessionKey : null;
} catch { } catch {
return null; return null;
} }
} }
export function clearClaudeSession(sessionKey: string): void { export function clearClaudeSession(sessionKey: string, userId: number): void {
sendFire('claude', { type: 'claude:clear-session', id: nextId(), sessionKey }); sendFire('claude', { type: 'claude:clear-session', id: nextId(), sessionKey, userId });
} }
// Turn output arrives finished and already durable: the agent translated it and committed it to // Turn output arrives finished and already durable: the agent translated it and committed it to
+76 -18
View File
@@ -6,6 +6,8 @@ import type { ChatEvent, PromptImage } from '../../api/chat/types';
import type { ClaudeSpawnParams, ClaudeSpawnStreamingParams, ClaudeCodeResult, LiveClaudeSession } from '../protocol'; import type { ClaudeSpawnParams, ClaudeSpawnStreamingParams, ClaudeCodeResult, LiveClaudeSession } from '../protocol';
import { setClaudeSession, clearClaudeSession, getClaudeSession } from './state'; import { setClaudeSession, clearClaudeSession, getClaudeSession } from './state';
import { createParseState, processMessage } from './stream-parser'; import { createParseState, processMessage } from './stream-parser';
import { spawnClaudeAsMember } from './spawn-as-member';
import { claudeBinIn } from '@@/os-user-claude';
const SEND_TIMEOUT_MS = 30 * 60 * 1000; const SEND_TIMEOUT_MS = 30 * 60 * 1000;
@@ -59,7 +61,7 @@ type ClaudeCodeOutput = {
export async function spawnClaude(params: ClaudeSpawnParams): Promise<ClaudeCodeResult> { export async function spawnClaude(params: ClaudeSpawnParams): Promise<ClaudeCodeResult> {
const { prompt, sessionKey } = params; const { prompt, sessionKey } = params;
const existingSession = getClaudeSession(sessionKey); const existingSession = getClaudeSession(sessionKey, params.userId);
const claudeArgs = [CLAUDE_BIN, '-p', prompt, '--dangerously-skip-permissions', '--output-format', 'json']; const claudeArgs = [CLAUDE_BIN, '-p', prompt, '--dangerously-skip-permissions', '--output-format', 'json'];
@@ -118,7 +120,7 @@ export async function spawnClaude(params: ClaudeSpawnParams): Promise<ClaudeCode
} }
if (output.session_id) { if (output.session_id) {
setClaudeSession(sessionKey, output.session_id); setClaudeSession(sessionKey, output.session_id, params.userId);
} }
return { return {
@@ -165,6 +167,8 @@ type SdkUserMessage = {
type PersistentSession = { type PersistentSession = {
sessionKey: string; sessionKey: string;
/** Whose session this is. The map is global and `sessionKey` arrives in a client message. */
userId: number;
query: Query; query: Query;
pushTurn: (prompt: string, images?: PromptImage[]) => void; pushTurn: (prompt: string, images?: PromptImage[]) => void;
closeInput: () => void; closeInput: () => void;
@@ -290,7 +294,8 @@ function armIdle(session: PersistentSession): void {
armIdle(session); armIdle(session);
return; return;
} }
killClaudeSession(session.sessionKey); // The idle GC is this process acting on its own session, so it passes the session's own owner.
killClaudeSession(session.sessionKey, session.userId);
}, IDLE_TIMEOUT_MS); }, IDLE_TIMEOUT_MS);
} }
@@ -301,6 +306,7 @@ function createSession(params: ClaudeSpawnStreamingParams, onEvent: (event: Chat
const session: PersistentSession = { const session: PersistentSession = {
sessionKey, sessionKey,
userId: params.userId,
query: undefined as unknown as Query, query: undefined as unknown as Query,
pushTurn: () => {}, pushTurn: () => {},
closeInput: () => input.close(), closeInput: () => input.close(),
@@ -314,13 +320,15 @@ function createSession(params: ClaudeSpawnStreamingParams, onEvent: (event: Chat
// Strip the nested-session guard vars so the SDK can spawn `claude` (mirrors the old spawn env clean). // Strip the nested-session guard vars so the SDK can spawn `claude` (mirrors the old spawn env clean).
const { CLAUDECODE: _c, CLAUDE_CODE_ENTRYPOINT: _e, CLAUDE_CODE_SSE_PORT: _s, ...cleanEnv } = process.env; const { CLAUDECODE: _c, CLAUDE_CODE_ENTRYPOINT: _e, CLAUDE_CODE_SSE_PORT: _s, ...cleanEnv } = process.env;
const resumeId = getClaudeSession(sessionKey) ?? params.resumeSessionId; const resumeId = getClaudeSession(sessionKey, params.userId) ?? params.resumeSessionId;
const subModel = params.model?.split('/')[1]; const subModel = params.model?.split('/')[1];
const q = query({ const q = query({
prompt: input.gen as AsyncIterable<SdkUserMessage>, prompt: input.gen as AsyncIterable<SdkUserMessage>,
options: { options: {
cwd: params.cwd ?? HOST_HOME, // HOST_HOME is this process's home — the owner's. Defaulting a member's turn to it would start them in
// a directory they cannot read, and the failure would look like a broken agent rather than a wrong cwd.
cwd: params.cwd ?? params.member?.home ?? HOST_HOME,
permissionMode: 'bypassPermissions', permissionMode: 'bypassPermissions',
allowDangerouslySkipPermissions: true, allowDangerouslySkipPermissions: true,
includePartialMessages: true, includePartialMessages: true,
@@ -347,7 +355,18 @@ function createSession(params: ClaudeSpawnStreamingParams, onEvent: (event: Chat
], ],
}, },
abortController: abort, abortController: abort,
pathToClaudeCodeExecutable: CLAUDE_BIN, // A member's turn runs their own install as their own Linux account; the owner's runs as it always has.
//
// `settingSources` is why the binary and the spawn have to move together: it makes `~/.claude`
// authoritative for settings, and `~` is decided by the HOME the process gets. Pointing the SDK at a
// member's binary while spawning as the service user would read the OWNER'S settings and credential
// while executing the member's code — the worst of both, and it would look like it worked.
...(params.member
? {
pathToClaudeCodeExecutable: claudeBinIn(params.member.home),
spawnClaudeCodeProcess: spawnClaudeAsMember(params.member),
}
: { pathToClaudeCodeExecutable: CLAUDE_BIN }),
settingSources: ['user', 'project', 'local'], settingSources: ['user', 'project', 'local'],
env: cleanEnv as Record<string, string>, env: cleanEnv as Record<string, string>,
stderr: (d: string) => { stderr: (d: string) => {
@@ -355,7 +374,15 @@ function createSession(params: ClaudeSpawnStreamingParams, onEvent: (event: Chat
}, },
...(subModel ? { model: subModel } : {}), ...(subModel ? { model: subModel } : {}),
...(resumeId ? { resume: resumeId } : {}), ...(resumeId ? { resume: resumeId } : {}),
...(mcpHostPath ? { extraArgs: { 'mcp-config': mcpHostPath } } : {}), // The owner's MCP config, and only ever the owner's. `mcpHostPath` is module-level, written once at
// this process's bootstrap, and its `env` carries OFFICER_AUTH_TOKEN — a JWT that signs as the owner.
// Handing it to a member's turn would either spawn their MCP server holding the owner's token, or (once
// that file is 0600, which it now is) point their `claude` at a file it cannot read and fail obscurely.
//
// So a member gets no MCP config at all. What they SHOULD get — their own generated config with a token
// scoped to them, or nothing until per-user tools exist — is an open design question; `undefined` is
// the correct answer until it is settled, and is strictly better than the owner's.
...(mcpHostPath && !params.member ? { extraArgs: { 'mcp-config': mcpHostPath } } : {}),
}, },
}); });
@@ -427,7 +454,7 @@ function createSession(params: ClaudeSpawnStreamingParams, onEvent: (event: Chat
for await (const msg of q as AsyncGenerator<Record<string, unknown>>) { for await (const msg of q as AsyncGenerator<Record<string, unknown>>) {
processMessage(msg, state, { processMessage(msg, state, {
onEvent: emit, onEvent: emit,
onSessionId: (id: string) => setClaudeSession(sessionKey, id), onSessionId: (id: string) => setClaudeSession(sessionKey, id, params.userId),
}); });
} }
} catch (err) { } catch (err) {
@@ -450,6 +477,15 @@ export async function spawnClaudeStreaming(
onEvent: (event: ChatEvent) => void, onEvent: (event: ChatEvent) => void,
): Promise<void> { ): Promise<void> {
let session = sessions.get(params.sessionKey); let session = sessions.get(params.sessionKey);
if (session && session.userId !== params.userId) {
// A live session belongs to whoever started it. Without this, handing over someone else's `sessionKey`
// pushes a turn into their conversation and streams their agent's output back — the chat equivalent of
// resuming another account's shell, which `4d4a253f` refused for the pty sidecar.
//
// Throws rather than silently starting a fresh session under the same key: the caller asked to continue a
// specific conversation, and quietly giving them a different one is its own kind of wrong.
throw new Error('that chat session belongs to another account');
}
if (session) { if (session) {
session.emit = onEvent; // adopt the latest emitter (equivalent across turns; keeps events flowing) session.emit = onEvent; // adopt the latest emitter (equivalent across turns; keeps events flowing)
} else { } else {
@@ -459,8 +495,8 @@ export async function spawnClaudeStreaming(
} }
/** Interrupt the current turn but KEEP the session alive (the "stop" button). */ /** Interrupt the current turn but KEEP the session alive (the "stop" button). */
export async function interruptClaudeSession(sessionKey: string): Promise<boolean> { export async function interruptClaudeSession(sessionKey: string, userId: number): Promise<boolean> {
const session = sessions.get(sessionKey); const session = ownedSession(sessionKey, userId);
if (!session) return false; if (!session) return false;
// Set before the await: the failed `result` can arrive while interrupt() is still resolving, and the // Set before the await: the failed `result` can arrive while interrupt() is still resolving, and the
// consumer loop reads this flag to tell a stop from a fault. // consumer loop reads this flag to tell a stop from a fault.
@@ -478,8 +514,8 @@ export async function interruptClaudeSession(sessionKey: string): Promise<boolea
} }
/** Fully tear the session down (the "disconnect" action / idle GC): abort the query + close input. */ /** Fully tear the session down (the "disconnect" action / idle GC): abort the query + close input. */
export function killClaudeSession(sessionKey: string): boolean { export function killClaudeSession(sessionKey: string, userId: number): boolean {
const session = sessions.get(sessionKey); const session = ownedSession(sessionKey, userId);
if (!session) return false; if (!session) return false;
if (session.idleTimer) clearTimeout(session.idleTimer); if (session.idleTimer) clearTimeout(session.idleTimer);
if (session.stallTimer) clearTimeout(session.stallTimer); if (session.stallTimer) clearTimeout(session.stallTimer);
@@ -497,10 +533,26 @@ export function killClaudeSession(sessionKey: string): boolean {
return true; return true;
} }
export function clearSession(sessionKey: string): void { export function clearSession(sessionKey: string, userId: number): void {
// Only clears a mapping that is theirs. `getClaudeSession` already refuses a mismatch, so this asks it
// first rather than reimplementing the check.
if (!getClaudeSession(sessionKey, userId)) return;
clearClaudeSession(sessionKey); clearClaudeSession(sessionKey);
} }
/**
* The live session under this key, **only if it belongs to the caller**.
*
* Undefined for both "no such session" and "not yours", deliberately: every caller of this treats the two the
* same, and a distinct answer for the second would tell a guesser that a session exists under a key they do
* not own which is the whole thing being defended against.
*/
function ownedSession(sessionKey: string, userId: number): PersistentSession | undefined {
const session = sessions.get(sessionKey);
if (!session || session.userId !== userId) return undefined;
return session;
}
/** /**
* Everything this process is holding, with the two facts that decide whether it is busy. * Everything this process is holding, with the two facts that decide whether it is busy.
* *
@@ -508,12 +560,16 @@ export function clearSession(sessionKey: string): void {
* alone could not distinguish a session mid-turn from one merely open, which is the whole question a * alone could not distinguish a session mid-turn from one merely open, which is the whole question a
* caller has. These are the same two fields `armIdle` consults before collecting a session. * caller has. These are the same two fields `armIdle` consults before collecting a session.
*/ */
export function listSessions(): LiveClaudeSession[] { export function listSessions(userId: number): LiveClaudeSession[] {
return Array.from(sessions.values()).map((session) => ({ // Filtered, not just labelled. Enumerating every live session is a disclosure on its own, before anyone
// acts on one: it names other accounts' conversations and says which are busy.
return Array.from(sessions.values())
.filter((session) => session.userId === userId)
.map((session) => ({
sessionKey: session.sessionKey, sessionKey: session.sessionKey,
// The only place this mapping exists. Without it a caller cannot find the transcript, because the // The only place this mapping exists. Without it a caller cannot find the transcript, because the
// key is officer's handle and the filename is Claude's id. // key is officer's handle and the filename is Claude's id.
claudeSessionId: getClaudeSession(session.sessionKey) ?? null, claudeSessionId: getClaudeSession(session.sessionKey, session.userId) ?? null,
isGenerating: session.isGenerating, isGenerating: session.isGenerating,
pendingTasks: session.pendingTasks.size, pendingTasks: session.pendingTasks.size,
})); }));
@@ -527,6 +583,8 @@ export function listSessions(): LiveClaudeSession[] {
* nothing and if *this* process was the one that restarted, the session is simply absent and the turn * nothing and if *this* process was the one that restarted, the session is simply absent and the turn
* it was running is gone, however alive the client still believes it to be. * it was running is gone, however alive the client still believes it to be.
*/ */
export function isSessionGenerating(sessionKey: string): boolean { export function isSessionGenerating(sessionKey: string, userId: number): boolean {
return sessions.get(sessionKey)?.isGenerating ?? false; // "Not yours" answers the same as "no such session": false. The caller uses this to decide whether to end
// a turn it believes is running, and its own turn is the only one it can be right about.
return ownedSession(sessionKey, userId)?.isGenerating ?? false;
} }
@@ -0,0 +1,96 @@
import { describe, expect, test } from 'bun:test';
import { rmSync, statSync } from 'node:fs';
import { join } from 'node:path';
import { runAsArgv } from '@@/os-user';
import { claudeBinIn } from '@@/os-user-claude';
import { spawnClaudeAsMember } from './spawn-as-member';
// Does the privilege drop actually work? The only question left that can still change the design.
//
// ── Why this exists as a separate, opt-in file ──
//
// The plan was circular: don't lift the chat gates until a member turn has been watched running, but a member
// turn goes through chat, and chat refuses non-owners — so with the gates up there is no turn to watch, and
// with them down the thing we wanted proven has already shipped.
//
// This breaks the loop by calling the hook directly. No gate, no chat, no SDK: just `spawnClaudeAsMember`
// against a real provisioned account, asserting the child runs as them. `setpriv` breaking the transport is
// exactly the class of failure that should not first appear in someone's live conversation.
//
// ── Running it ──
//
// OFFICER_TEST_MEMBER=green OFFICER_TEST_MEMBER_HOME=/…/data/<email>/home \
// bun test src/servers/sidecar/claude/spawn-as-member.live.test.ts
//
// Skips entirely without those, because it needs a provisioned member with `claude` installed — which exists
// on the production host and on no developer machine. A skipped run is not a pass; the log says which it was.
const osUser = process.env.OFFICER_TEST_MEMBER;
const home = process.env.OFFICER_TEST_MEMBER_HOME;
const live = Boolean(osUser && home);
// The uid that actually ran, established by what the kernel wrote rather than by what any process said.
//
// The first version read `/proc/<child.pid>/status`, and `child.pid` is **sudo** — whose real uid is
// legitimately the service user's until it execs down through `setpriv` to the member. So it asserted against
// the wrapper and failed on a working privilege drop.
//
// Having the final process create a file and reading its owner keeps the property that mattered — nothing
// self-reports — while observing the process that matters. A process cannot forge the uid that owns a file it
// created.
describe.if(live)('spawnClaudeAsMember against a real account', () => {
test('runs as the member, and the kernel says so', async () => {
const spawn = spawnClaudeAsMember({ osUser: osUser!, home: home! });
// Their own binary is the only command the hook permits, so identity is proven by a file the CLI's own
// process leaves behind rather than by running `id`.
const child = spawn({
command: claudeBinIn(home!),
args: ['--version'],
cwd: home!,
env: {},
signal: new AbortController().signal,
}) as unknown as { stdout: NodeJS.ReadableStream; on: (e: string, cb: (c: number | null) => void) => void };
let out = '';
child.stdout.on('data', (chunk: Buffer) => {
out += chunk.toString();
});
const code = await new Promise<number | null>((resolve) => child.on('exit', resolve));
expect(code).toBe(0);
expect(out).toMatch(/\d+\.\d+\.\d+/);
});
test('the privilege drop lands on the member, proven by file ownership', async () => {
// A second spawn whose only job is to leave evidence. `sh` is refused by the binary check, so this goes
// through `runAsArgv` directly — the same argv the hook builds, minus the SDK's shape.
const probe = join(home!, `.spawn-probe-${Date.now()}`);
const argv = runAsArgv(osUser!, ['sh', '-c', `: > "$1"`, '_', probe]);
const proc = Bun.spawn(argv, { stdout: 'pipe', stderr: 'pipe' });
expect(await proc.exited).toBe(0);
const owner = statSync(probe).uid;
rmSync(probe, { force: true });
expect(owner).toBeGreaterThanOrEqual(1000);
expect(owner).not.toBe(process.getuid?.());
});
test('refuses a binary that is not theirs', () => {
const spawn = spawnClaudeAsMember({ osUser: osUser!, home: home! });
expect(() =>
spawn({
command: '/bin/sh',
args: ['-c', 'echo nope'],
cwd: home!,
env: {},
signal: new AbortController().signal,
}),
).toThrow(/expected their own/);
});
});
test.if(!live)('live spawn test skipped — set OFFICER_TEST_MEMBER and OFFICER_TEST_MEMBER_HOME', () => {
// Present so a run without the env vars says so out loud rather than reporting an empty file as success.
expect(live).toBe(false);
});
@@ -0,0 +1,43 @@
import { describe, expect, test } from 'bun:test';
import { PERMITTED_ENV, assertEnvSafe } from './spawn-as-member';
// Pins the two guards standing between a member's agent turn and the owner's credential.
//
// Both of them shipped dead before this test existed, and in both cases the reason was the same: they were
// written inside the spawn closure, where the only way to reach them is to spawn, and the passing path spawns
// `sudo`. So nothing ever demonstrated them firing, and "it looks right" carried the weight. That is what
// these tests are for — not the happy path, which is obvious, but the two edits a future reader will actually
// make.
const OWNER_CRED = 'ANTHROPIC_API_KEY';
describe('assertEnvSafe', () => {
test('the shipping lists are clean', () => {
// The regression this exists to catch: somebody adds a credential to ALLOWED_ENV.
expect(() => assertEnvSafe(PERMITTED_ENV, { HOME: '/home/x' }, 'x')).not.toThrow();
});
test('throws when a credential is in the allowlist — guards the constant', () => {
// The realistic dangerous edit. Note the credential is NOT in childEnv: the point is that the *list*
// permits it, so the next call that inherits one would pass it through silently.
const poisoned = new Set([...PERMITTED_ENV, OWNER_CRED]);
expect(() => assertEnvSafe(poisoned, { HOME: '/home/x' }, 'green')).toThrow(/is in the allowlist/);
});
test('throws when the built env carries an unvetted key — guards the construction', () => {
expect(() => assertEnvSafe(PERMITTED_ENV, { HOME: '/home/x', POSTGRES_URL: 'postgres://…' }, 'green')).toThrow(
/unvetted env/,
);
});
test('names the offending variable, so the error is actionable', () => {
expect(() => assertEnvSafe(PERMITTED_ENV, { JWT_SECRET: 'x' }, 'green')).toThrow(/JWT_SECRET/);
});
test('every NEVER_ENV name would be caught if it were permitted', () => {
// Guards against the list being quietly narrowed as well as the allowlist being widened.
for (const name of ['ANTHROPIC_BASE_URL', 'CLAUDE_CODE_OAUTH_TOKEN', 'POSTGRES_URL', 'JWT_SECRET']) {
expect(() => assertEnvSafe(new Set([...PERMITTED_ENV, name]), {}, 'green')).toThrow(name);
}
});
});
@@ -0,0 +1,196 @@
import type { SpawnOptions, SpawnedProcess } from '@anthropic-ai/claude-agent-sdk';
import { spawn } from 'node:child_process';
import { join, resolve } from 'node:path';
import { runAsArgv } from '@@/os-user';
import { claudeBinIn } from '@@/os-user-claude';
// Running a member's agent turn as the member, without a second sidecar.
//
// ── Why the sidecar stays as the service user and only the CLI drops privileges ──
//
// The obvious reading of "each member runs their own Claude" is a second `officer-agent` running under their
// uid. That does not work, and the reason is worth writing down because it looks like an implementation
// detail and is actually a boundary. This sidecar needs `POSTGRES_URL` (it imports `officerdb`) and the JWT
// signing secret — it mints a 30-day owner token for the MCP tools. A process running as a member with those
// two values in its environment can read every account's data and sign a token as the owner, which is
// strictly more than a member's shell can do. `docs/per-user-linux-accounts.md` already forbids exactly this:
// `.env` is 600 and a boot check refuses to start with OS users enabled while it is readable.
//
// So the split is: the platform glue stays the service user's, and the thing that runs the member's code and
// holds the member's credential — `claude` itself — is theirs. The member's agent process genuinely is their
// own, in their home, with their login and their binary. Only the harness around it is shared, and the
// harness is the part that must not be.
//
// This is the pty sidecar's shape, which is the established one here: one process, per-request identity,
// privileges dropped at the point where the member's code starts (`sidecar/pty/sessions.mjs:85-93`).
//
// ── Why an allowlist and not a filter ──
//
// `runAsArgv` uses `setpriv --reset-env`, so nothing crosses into the member's process unless it is written
// into the argv (`os-user.ts:120-124`). That is the whole safety property here, and it points one way: build
// the child's environment from an allowlist rather than by subtracting the dangerous names from this
// process's. A denylist has to be right about every variable that exists now and every one added later —
// including `POSTGRES_URL`, the JWT secret, and the owner's `ANTHROPIC_API_KEY`, all of which are sitting in
// this process's environment as it makes this call. An allowlist is wrong in the direction that fails safe.
/** Everything needed to run a turn as one member. Resolved by the platform, never taken from a client. */
export type MemberRun = {
/** Their Linux account, from `users.osUser`. */
osUser: string;
/** Their home — `DATA_PATH/<email>/home`, per `resolveHomeDir`. */
home: string;
};
/**
* The variables a member's `claude` is allowed to inherit.
*
* Deliberately short. `setpriv --init-groups --reset-env` already supplies HOME, USER, LOGNAME, SHELL and
* PATH from their passwd entry, so this list is only what the harness itself needs on top of that.
*
* **Nothing here may ever be a secret.** These are passed as `env K=V …` inside the argv, which means every
* one of them is visible in `/proc/<pid>/cmdline` to every account on the box. That is fine for locale and
* terminal settings and is not fine for a token a credential belongs in the member's own `~/.claude`, which
* is theirs and mode 600, not on a command line.
*/
const ALLOWED_ENV = ['LANG', 'LC_ALL', 'TERM', 'TZ', 'NO_COLOR', 'CLAUDE_CODE_ENTRYPOINT'] as const;
/** Set by `memberEnv` itself rather than inherited, so vetted the same way. */
const ALSO_ALLOWED = ['HOME', 'CLAUDE_CONFIG_DIR'] as const;
/**
* Credential names that must never appear in the allowlist. **This guards the constant, not the instance.**
*
* Two dead checks were written here before this one worked, and the distinction is the whole lesson. A
* denylist tested against `childEnv` cannot fire, because `memberEnv` builds that object *from* `ALLOWED_ENV`.
* Inverting it to a subset test cannot fire either, for the same reason and it is strictly worse, because
* widening `ALLOWED_ENV` widens the permitted set in the same motion, so the one realistic dangerous edit
* (somebody adds a credential to the allowlist) stops throwing and starts passing silently.
*
* Tested against the *list*, it fires on exactly that edit. Being incomplete is then survivable: a name this
* misses degrades to the status quo rather than to false confidence. The Anthropic and Claude entries are what
* the installed SDK reads; the last two are what make this process more privileged than a member's shell.
*/
const NEVER_ENV = [
'ANTHROPIC_BASE_URL',
'ANTHROPIC_API_KEY',
'ANTHROPIC_AUTH_TOKEN',
'ANTHROPIC_FOUNDRY_API_KEY',
'_CLAUDE_CODE_ASSUME_FIRST_PARTY_BASE_URL',
'CLAUDE_CODE_OAUTH_TOKEN',
'CLAUDE_CODE_OAUTH_REFRESH_TOKEN',
'CLAUDE_CODE_SESSION_ACCESS_TOKEN',
'CLAUDE_CODE_CLIENT_KEY',
'CLAUDE_API_KEY',
'POSTGRES_URL',
'JWT_SECRET',
];
/**
* The `claude` config directory for a member.
*
* `--reset-env` already sets HOME to their home, so `~/.claude` would resolve correctly on its own. This is
* set explicitly anyway because "which account's credential did this turn use" is the single most important
* question in this file, and it should be answerable by reading one line rather than by reasoning about what
* `setpriv` does to HOME.
*/
export const memberClaudeConfigDir = (home: string): string => join(home, '.claude');
/** Everything a member's turn may carry. Exported so a test can assert the shipping lists are clean. */
export const PERMITTED_ENV: ReadonlySet<string> = new Set<string>([...ALLOWED_ENV, ...ALSO_ALLOWED]);
/**
* The two env guards, as one pure function so they can be tested.
*
* They were unreachable from a test while they lived inside the spawn closure the only way to exercise them
* was to spawn, and the passing path spawns `sudo`. Which is how both earlier versions of this guard shipped
* dead: nothing could demonstrate them firing.
*
* @param permitted the names allowed for this turn parameterised so a test can pass a poisoned list
* @param childEnv what `memberEnv` actually produced
*/
export function assertEnvSafe(permitted: ReadonlySet<string>, childEnv: Record<string, string>, who: string): void {
// Guards the constant: a credential added to the allowlist throws rather than reaching a member.
const leaked = NEVER_ENV.filter((name) => permitted.has(name));
if (leaked.length) throw new Error(`refusing to run ${who}'s agent: ${leaked.join(', ')} is in the allowlist`);
// Guards the construction: a key `memberEnv` invents that nobody vetted.
const unexpected = Object.keys(childEnv).filter((name) => !permitted.has(name));
if (unexpected.length) throw new Error(`refusing to run ${who}'s agent with unvetted env: ${unexpected.join(', ')}`);
}
/** Build the child environment for a member's turn: allowlist in, everything else absent. */
function memberEnv(run: MemberRun, inherited: Record<string, string | undefined>): Record<string, string> {
const env: Record<string, string> = {};
for (const name of ALLOWED_ENV) {
const value = inherited[name];
if (value !== undefined) env[name] = value;
}
env.HOME = run.home;
env.CLAUDE_CONFIG_DIR = memberClaudeConfigDir(run.home);
return env;
}
/**
* The SDK's spawn hook, bound to one member.
*
* `spawnClaudeCodeProcess` (`sdk.d.ts:951`, "use this to run Claude Code in VMs, containers, or remote
* environments") is what makes this possible at all. The plan of record assumed the SDK had nowhere to put a
* uid and that a member's turn therefore had to become its own process a change of shape rather than a
* flag. It is a flag.
*
* `node:child_process` rather than `Bun.spawn`, for two reasons: its return value already satisfies
* `SpawnedProcess` (a Writable stdin, a Readable stdout, `kill`, `on('exit')`), which Bun's does not Bun
* gives web streams and no emitter and `Bun.spawn` silently ignores `uid`/`gid` anyway, which is why
* `runAs` exists and why `os-user.test.ts` pins that behaviour.
*/
export function spawnClaudeAsMember(run: MemberRun): (options: SpawnOptions) => SpawnedProcess {
return ({ command, args, cwd, env, signal }: SpawnOptions): SpawnedProcess => {
const childEnv = memberEnv(run, env);
assertEnvSafe(PERMITTED_ENV, childEnv, run.osUser);
// The binary must be theirs — established without touching their filesystem.
//
// Both operands are computed by the platform from the same function: `claude-manager.ts` sets
// `pathToClaudeCodeExecutable: claudeBinIn(member.home)`, and this recomputes it. So string equality
// establishes exactly what the check is for, and if the SDK ever normalises the path it fails closed and
// loudly rather than silently.
//
// This was `realpathSync` on both sides for one commit, to survive an upstream that resolves symlinks —
// and there is no such upstream, because the platform controls both ends. The cost was total: a member's
// home is 700, the platform is `other`, so `realpathSync` threw EACCES, the catch turned that into
// "not their binary", and EVERY member turn would have been refused forever the moment the gates moved.
// Verified on the production host against a byte-identical path. Failing closed was the right direction
// and it made the feature impossible rather than unsafe.
//
// The alternative — granting the service user traverse — needs `x` on `.local`, `.local/share`,
// `.local/share/claude` and `versions/`, which reopens a decision made deliberately in `16` across a whole
// subtree rather than one directory.
const expectedBin = claudeBinIn(run.home);
if (resolve(command) !== expectedBin) {
throw new Error(`refusing to run ${command} as ${run.osUser}; expected their own ${expectedBin}`);
}
// `env K=V …` inside the argv, because `--reset-env` clears anything handed to `setpriv` itself. This is
// the only channel through which a variable can reach the member's process.
const assignments = Object.entries(childEnv).map(([name, value]) => `${name}=${value}`);
const [launcher, ...launcherArgs] = runAsArgv(run.osUser, ['env', ...assignments, command, ...args]);
// Unreachable — `runAsArgv` always returns `sudo` first and throws on an empty command. Written rather
// than asserted away because the alternative under `noUncheckedIndexedAccess` is a default that would
// silently pick a launcher, and a wrong launcher here means a turn running as the wrong user.
if (!launcher) throw new Error('runAsArgv returned an empty argv');
const child = spawn(launcher, launcherArgs, {
// Their home, not this process's. A cwd outside it would be readable by the turn only if the member
// could read it anyway — the kernel is the check here, not this line — but defaulting to their home is
// what makes an unqualified turn behave like their shell.
cwd: cwd ?? run.home,
stdio: ['pipe', 'pipe', 'pipe'],
signal,
});
// Non-null by construction: 'pipe' on all three above. The SDK's interface wants them non-nullable and
// node types them as possibly-null because other stdio modes exist.
return child as unknown as SpawnedProcess;
};
}
+62 -9
View File
@@ -3,9 +3,22 @@ import { mkdirSync, existsSync, readFileSync, writeFileSync, unlinkSync } from '
const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data'); const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data');
/**
* A resumable session, and whose it is.
*
* `userId` exists because the map is global to this sidecar and `sessionKey` travels in a client message. The
* pty sidecar learned this first (`4d4a253f`): *"re-attaching to a session belonging to another account is
* refused, otherwise a member resumes someone else's shell by guessing an id that travels in a query
* string."* Chat never got the same treatment, because both gates made it unreachable and therefore invisible.
*
* Stored rather than derived: there is nothing in a `sessionKey` or a Claude transcript uuid that says who
* owns it, so ownership has to be written down at the moment it is created.
*/
export type SessionRecord = { userId: number; claudeSessionId: string };
export type PersistedState = { export type PersistedState = {
proxySecret: string; proxySecret: string;
claudeSessions: Record<string, string>; // sessionKey → Claude Code session_id claudeSessions: Record<string, SessionRecord>; // sessionKey → whose, and which transcript
}; };
const DEFAULT_STATE: PersistedState = { const DEFAULT_STATE: PersistedState = {
@@ -41,7 +54,11 @@ function ensureDir() {
} }
} }
export function loadState(): PersistedState { /**
* @param ownerUserId who existing sessions belong to see the migration below. Omitted by the proxy
* process, which holds no sessions; legacy entries are then dropped rather than attributed to a guess.
*/
export function loadState(ownerUserId?: number): PersistedState {
ensureDir(); ensureDir();
try { try {
if (!existsSync(stateFile)) { if (!existsSync(stateFile)) {
@@ -50,6 +67,7 @@ export function loadState(): PersistedState {
} }
const text = readFileSync(stateFile, 'utf-8'); const text = readFileSync(stateFile, 'utf-8');
currentState = { ...DEFAULT_STATE, ...JSON.parse(text) }; currentState = { ...DEFAULT_STATE, ...JSON.parse(text) };
currentState.claudeSessions = migrateSessions(currentState.claudeSessions, ownerUserId);
return currentState; return currentState;
} catch { } catch {
currentState = { ...DEFAULT_STATE }; currentState = { ...DEFAULT_STATE };
@@ -57,6 +75,28 @@ export function loadState(): PersistedState {
} }
} }
/**
* Entries used to be a bare `sessionKey → transcript uuid` string. Adopt those to the owner.
*
* Safe because it is a statement about the past rather than a guess: until this commit, `api/chat/chat.ts`
* and the chat socket refused every non-owner, so nothing but the owner could ever have created one. The
* alternative dropping unrecognised entries would silently lose the owner's resumable history on upgrade,
* and "my old chats stopped resuming" is a bad way to discover a migration.
*/
function migrateSessions(raw: Record<string, unknown>, ownerUserId?: number): Record<string, SessionRecord> {
const out: Record<string, SessionRecord> = {};
for (const [key, value] of Object.entries(raw ?? {})) {
if (typeof value === 'string') {
if (ownerUserId !== undefined) out[key] = { userId: ownerUserId, claudeSessionId: value };
} else if (value && typeof value === 'object' && 'claudeSessionId' in value && 'userId' in value) {
out[key] = value as SessionRecord;
}
// Anything else is unreadable and dropped: a malformed entry cannot be attributed to anyone, and
// guessing an owner for it is exactly the mistake this whole change exists to stop.
}
return out;
}
export async function saveState(): Promise<void> { export async function saveState(): Promise<void> {
ensureDir(); ensureDir();
await Bun.write(stateFile, JSON.stringify(currentState, null, 2)); await Bun.write(stateFile, JSON.stringify(currentState, null, 2));
@@ -83,9 +123,10 @@ export function updateState(patch: Partial<PersistedState>): void {
* There is nothing to debounce: `onSessionId` fires on every message but with the same id, so the * There is nothing to debounce: `onSessionId` fires on every message but with the same id, so the
* equality guard collapses it to one write per session, and clearing happens once. * equality guard collapses it to one write per session, and clearing happens once.
*/ */
export function setClaudeSession(sessionKey: string, sessionId: string): void { export function setClaudeSession(sessionKey: string, sessionId: string, userId: number): void {
if (currentState.claudeSessions[sessionKey] === sessionId) return; const existing = currentState.claudeSessions[sessionKey];
currentState.claudeSessions[sessionKey] = sessionId; if (existing?.claudeSessionId === sessionId && existing.userId === userId) return;
currentState.claudeSessions[sessionKey] = { userId, claudeSessionId: sessionId };
writeThrough(); writeThrough();
} }
@@ -101,8 +142,17 @@ function writeThrough(): void {
}); });
} }
export function getClaudeSession(sessionKey: string): string | undefined { /**
return currentState.claudeSessions[sessionKey]; * The transcript for this session key, **only if it belongs to the caller**.
*
* A mismatch returns undefined rather than throwing: to the caller it is simply "no session to resume", which
* is the truthful answer there is no session of theirs under that key. Throwing would confirm that somebody
* else's exists, which is the one thing a guesser learns from.
*/
export function getClaudeSession(sessionKey: string, userId: number): string | undefined {
const record = currentState.claudeSessions[sessionKey];
if (!record || record.userId !== userId) return undefined;
return record.claudeSessionId;
} }
/** /**
@@ -118,11 +168,14 @@ export function getClaudeSession(sessionKey: string): string | undefined {
* Newest wins: a transcript resumed under a fresh key leaves the old entry in place, and the caller wants * Newest wins: a transcript resumed under a fresh key leaves the old entry in place, and the caller wants
* the session generating now, not the one that produced the same file yesterday. * the session generating now, not the one that produced the same file yesterday.
*/ */
export function findSessionKeyByClaudeSession(claudeSessionId: string): string | undefined { export function findSessionKeyByClaudeSession(claudeSessionId: string, userId: number): string | undefined {
const keys = Object.keys(currentState.claudeSessions); const keys = Object.keys(currentState.claudeSessions);
for (let i = keys.length - 1; i >= 0; i--) { for (let i = keys.length - 1; i >= 0; i--) {
const key = keys[i]!; const key = keys[i]!;
if (currentState.claudeSessions[key] === claudeSessionId) return key; const record = currentState.claudeSessions[key];
// Scoped to the caller: this is the reattach hinge, and a browser holding a transcript uuid it should
// not have would otherwise be handed the session key that drives it.
if (record?.claudeSessionId === claudeSessionId && record.userId === userId) return key;
} }
return undefined; return undefined;
} }
+56 -9
View File
@@ -1,4 +1,4 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join, resolve } from 'node:path'; import { join, resolve } from 'node:path';
import { homedir } from 'node:os'; import { homedir } from 'node:os';
import type { SidecarCommand, SidecarEvent } from '../protocol'; import type { SidecarCommand, SidecarEvent } from '../protocol';
@@ -92,12 +92,40 @@ process.env.HOME = homeDir;
// Init per-user state paths // Init per-user state paths
initPaths(email); initPaths(email);
// ── One conversation must not be able to end the others ──
//
// Four crashes on the production host in one evening, one of them truncating the owner's turn mid-sentence:
//
// error: ProcessTransport is not ready for writing
// at write (…/claude-agent-sdk/sdk.mjs) ← no frames from our code
//
// It is a floating rejection inside the SDK's own input pump, so there is no `await` of ours to catch it. With
// no handler it reached the top level, Bun exited, PM2 restarted, and every live session on the machine died —
// not just the one whose transport hiccuped.
//
// That is `975673a` for the second time. That commit fixed the one path someone had thought of (a Postgres
// query throwing) and its own message named the consequence: "any Postgres restart killed every live agent
// session on the machine". The general case had no backstop at all.
//
// So: log it and stay up. A rejection nobody handled is a bug and this does not pretend otherwise — it makes
// it debuggable instead of fatal, and the log line is deliberately loud because a silently-surviving process
// is its own problem.
//
// `uncaughtException` is deliberately NOT handled the same way. A rejection leaves the process's own state
// intact; a synchronous throw that unwound to the top may not have, and continuing on a corrupted heap is a
// worse failure than restarting. The blast radius there is the same, which is an argument for the sessions
// being durable rather than for surviving anything at all cost.
process.on('unhandledRejection', (reason) => {
const detail = reason instanceof Error ? (reason.stack ?? reason.message) : String(reason);
console.error(`[agent] UNHANDLED REJECTION — session may be broken, process staying up:\n${detail}`);
});
if (!acquireLock()) { if (!acquireLock()) {
console.error(`[agent] another instance is already running for ${email} (lock file exists with live PID)`); console.error(`[agent] another instance is already running for ${email} (lock file exists with live PID)`);
process.exit(1); process.exit(1);
} }
loadState(); loadState(dbUser.id);
// ── MCP config ── // ── MCP config ──
@@ -129,7 +157,22 @@ function generateMcpConfig(): string {
}, },
}, },
}; };
writeFileSync(join(contextDir, 'mcp-host.json'), JSON.stringify(hostConfig)); // 0600, because this file's `env` block carries OFFICER_AUTH_TOKEN — a 30-day JWT that signs as the owner.
// It was written at the default 0644 inside a 755 directory, and `terminal` is granted to every role by
// default, so any member with a shell could `cat` it and hold owner-level API access against
// OFFICER_API_URL on loopback. Verified as a real member on the production host, not reasoned about.
//
// The mode is only the half of this that is code. The directory chain above it still allows traversal and
// listing, and a token that has been world-readable stays compromised however the file is chmod'ed
// afterwards — it has to be rotated. Both are the owner's, and both are written up in COMMS.
const mcpHostFile = join(contextDir, 'mcp-host.json');
writeFileSync(mcpHostFile, JSON.stringify(hostConfig), { mode: 0o600 });
// BOTH, and neither is redundant. `writeFileSync`'s `mode` reaches `open(2)`, which honours it only when it
// CREATES the file — on an existing one the call truncates and writes and the mode is ignored. So the
// creation mode alone fixes new installs and silently does nothing for every box already leaking, which is
// the entire exposed population. `chmodSync` is unconditional and idempotent, so the next bootstrap repairs
// a deployed install; the creation mode closes the window between `open` and `chmod` on a fresh write.
chmodSync(mcpHostFile, 0o600);
return join(contextDir, 'mcp-host.json'); return join(contextDir, 'mcp-host.json');
} }
@@ -226,34 +269,38 @@ async function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
} }
case 'claude:kill': case 'claude:kill':
claudeManager.killClaudeSession(cmd.sessionKey); claudeManager.killClaudeSession(cmd.sessionKey, cmd.userId);
sessionLog.drop(cmd.sessionKey); sessionLog.drop(cmd.sessionKey);
reply({ type: 'claude:killed', id: cmd.id }); reply({ type: 'claude:killed', id: cmd.id });
break; break;
case 'claude:interrupt': case 'claude:interrupt':
await claudeManager.interruptClaudeSession(cmd.sessionKey); await claudeManager.interruptClaudeSession(cmd.sessionKey, cmd.userId);
reply({ type: 'claude:interrupted', id: cmd.id }); reply({ type: 'claude:interrupted', id: cmd.id });
break; break;
case 'claude:list': case 'claude:list':
reply({ type: 'claude:sessions', id: cmd.id, sessions: claudeManager.listSessions() }); reply({ type: 'claude:sessions', id: cmd.id, sessions: claudeManager.listSessions(cmd.userId) });
break; break;
case 'claude:is-generating': case 'claude:is-generating':
reply({ type: 'claude:generating', id: cmd.id, generating: claudeManager.isSessionGenerating(cmd.sessionKey) }); reply({
type: 'claude:generating',
id: cmd.id,
generating: claudeManager.isSessionGenerating(cmd.sessionKey, cmd.userId),
});
break; break;
case 'claude:find-session': case 'claude:find-session':
reply({ reply({
type: 'claude:session-key', type: 'claude:session-key',
id: cmd.id, id: cmd.id,
sessionKey: findSessionKeyByClaudeSession(cmd.claudeSessionId) ?? null, sessionKey: findSessionKeyByClaudeSession(cmd.claudeSessionId, cmd.userId) ?? null,
}); });
break; break;
case 'claude:clear-session': case 'claude:clear-session':
claudeManager.clearSession(cmd.sessionKey); claudeManager.clearSession(cmd.sessionKey, cmd.userId);
sessionLog.drop(cmd.sessionKey); sessionLog.drop(cmd.sessionKey);
reply({ type: 'claude:session-cleared', id: cmd.id }); reply({ type: 'claude:session-cleared', id: cmd.id });
break; break;

Some files were not shown because too many files have changed in this diff Show More