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:
@@ -48,3 +48,6 @@ src/apps/officer-web/index.gen.html
|
||||
|
||||
# scratch scripts — never commit these
|
||||
*.tmp.ts
|
||||
|
||||
# Sidecar assets published at install time — copies of files that live in each sidecar's own tree.
|
||||
public/plugins/
|
||||
|
||||
@@ -126,7 +126,7 @@ history was deleted because it had drifted from the real schema. Treat the schem
|
||||
files, as the source of truth.
|
||||
|
||||
**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
|
||||
exceed Postgres's 63-character identifier limit: name it explicitly. See `src/databases/CLAUDE.md` →
|
||||
"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.
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
files you actually touched. `bun format` is only safe when the tree is otherwise clean.
|
||||
|
||||
|
||||
## Code Style
|
||||
|
||||
- **Paradigm**: functional — pure functions, immutability, composition
|
||||
- **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
|
||||
- **Exports**: named only, no defaults
|
||||
- **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
|
||||
- **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.
|
||||
|
||||
## 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.
|
||||
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
|
||||
*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` +
|
||||
`/chat/:sessionId`, `/jobs` + `/jobs/:id`, `/email` + `/email/:emailId`, `/headscale` +
|
||||
`/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
|
||||
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
|
||||
|
||||
- `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
|
||||
- `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
|
||||
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.
|
||||
- `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
|
||||
|
||||
@@ -14,10 +14,45 @@ the owner's OS user and can never be granted. Indirection there really is accide
|
||||
|
||||
## Multi-user
|
||||
|
||||
- [ ] **No way to create a second account.** `createUser` has one call site, `auth/bootstrap.ts`, gated
|
||||
on an empty user table. There is no signup route, no invite flow and no admin create-user
|
||||
handler, so every member on this instance was inserted into Postgres by hand. This is the
|
||||
blocker for onboarding anyone who is not already in the database.
|
||||
- [ ] **`deprovisionOsAccount` does not exist, and deleting a member leaves their whole Linux side.**
|
||||
`deleteUserHandler` removes the row and cascades the database; `userdel` never runs. Observed on the
|
||||
production host on 2026-08-12: a member deleted through the UI kept a working login shell, a running
|
||||
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
|
||||
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
|
||||
`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
|
||||
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.
|
||||
@@ -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.
|
||||
|
||||
- [ ] **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.
|
||||
|
||||
- [ ] **`markInterruptedJobs()` and `getOldestPendingJob()` are platform-wide.** The pipeline queue is a
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -60,8 +60,11 @@ for someone who does not. The prompt is the fork.
|
||||
|
||||
**Officer is the installer, never the owner.** Concretely:
|
||||
|
||||
- A real `docker-compose.yml` per service, written into a **user-owned directory**
|
||||
(`~/officer-services/<service>/`), from our template.
|
||||
- A real compose file per service, written into **`<root>/dockers/<id>/`**, from our template — using
|
||||
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.
|
||||
- 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
|
||||
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
|
||||
support Podman for people who want genuinely rootless?
|
||||
**`USER_UID` / `USER_GID` are set to the owner**, as the existing services already do. That answers the
|
||||
"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.
|
||||
- **`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** | 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. |
|
||||
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.
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
1. `user:` in compose, and Podman support for rootless.
|
||||
|
||||
@@ -26,16 +26,17 @@ module.exports = defineProfile({
|
||||
'officer-agent', // spawns `claude` — chat is dead without it
|
||||
'officer-opencode', // the alternative agent
|
||||
'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.
|
||||
// A light install simply is not running the thing behind it.
|
||||
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-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',
|
||||
|
||||
@@ -32,17 +32,18 @@ module.exports = defineProfile({
|
||||
// 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.
|
||||
'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: {
|
||||
// Cannot run on macOS at all.
|
||||
'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.
|
||||
'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',
|
||||
|
||||
@@ -16,26 +16,10 @@
|
||||
|
||||
import { mkdirSync, existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data');
|
||||
|
||||
// Mirrors the shape the owner's root grew organically. Most of these are also created on demand by
|
||||
// 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;
|
||||
// The list and DATA_PATH itself come from the platform rather than being restated here. The owner's
|
||||
// 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.
|
||||
import { DATA_PATH, USER_DIRS } from '../src/servers/data-path';
|
||||
|
||||
const DRY_RUN = process.env.DRY_RUN === '1';
|
||||
const emails = process.argv.slice(2).filter(Boolean);
|
||||
|
||||
+73
-27
@@ -14,7 +14,7 @@
|
||||
# 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,
|
||||
# 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 app itself is identical — every API route stays mounted, so the features whose sidecars
|
||||
@@ -230,6 +230,37 @@ case $PM in
|
||||
;;
|
||||
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
|
||||
install_pkg "${CORE_PKGS[@]}"
|
||||
ok "Installed: ${CORE_PKGS[*]}"
|
||||
@@ -476,13 +507,50 @@ else
|
||||
skip "bun symlink at /usr/local/bin/bun"
|
||||
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
|
||||
# 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
|
||||
# terminal and a chat.
|
||||
if is_light; then
|
||||
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
|
||||
|
||||
# ─── 7. Go ─────────────────────────────────────────────────────────────────────
|
||||
@@ -669,32 +737,10 @@ else
|
||||
ok "LazyVim starter installed at ~/.config/nvim"
|
||||
fi
|
||||
|
||||
# ─── 12. Terminal tools ──────────────────────────────────────────────────────
|
||||
# ─── 12. Terminal tools (starship is section 6b, outside the light skip) ─────
|
||||
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
|
||||
if [ -d "$HOME/.oh-my-zsh" ]; then
|
||||
@@ -809,7 +855,7 @@ else
|
||||
skip "claude (claude-code)"
|
||||
else
|
||||
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
|
||||
fi
|
||||
|
||||
|
||||
@@ -53,8 +53,6 @@ export function App() {
|
||||
<Route path="/chat/new/g/*" element={<Dashboard.SessionListPage isNew />} />
|
||||
<Route path="/chat/g/*" 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="/calendar" element={<Dashboard.CalendarScreen />} />
|
||||
<Route path="/contacts" element={<Dashboard.ContactsScreen />} />
|
||||
@@ -65,6 +63,7 @@ export function App() {
|
||||
<Route path="/headscale/:section" element={<Dashboard.HeadscaleScreen />} />
|
||||
<Route path="/photos" 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/:section" element={<Dashboard.JellyfinScreen />} />
|
||||
<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 { Background } from './Background';
|
||||
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 { usePageTitleSync } from '@/state/usePageTitle';
|
||||
import { RouteGate } from './RouteGate';
|
||||
|
||||
type DashboardLayoutProps = {
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
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
|
||||
// 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
|
||||
// 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 isTouch = useIsTouch();
|
||||
const { pathname } = useLocation();
|
||||
@@ -50,7 +56,10 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
|
||||
resetKeys={[pathname]}
|
||||
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>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { NavLink } from 'react-router';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { resolveIcon } from 'officerdev';
|
||||
import type { PluginManifest } from 'hooks/useCapabilities';
|
||||
|
||||
export type DockItem = {
|
||||
label: string;
|
||||
@@ -147,34 +149,67 @@ import {
|
||||
Contact,
|
||||
Clapperboard,
|
||||
GitBranch,
|
||||
Store,
|
||||
} 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: '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: '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: '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: 'Logs', to: '/task-logs', icon: ScrollText, color: '#94a3b8' },
|
||||
{ label: 'Terminal', to: '/terminal', icon: Monitor, color: '#f97316' },
|
||||
{ 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: 'Activity', to: '/activity', icon: Radio, color: '#f59e0b' },
|
||||
{ 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 { Loader2, ListOrdered } from 'lucide-react';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useCapabilities } from 'hooks/useCapabilities';
|
||||
|
||||
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 = () => {
|
||||
const client = useClient();
|
||||
const { can } = useCapabilities();
|
||||
const allowed = can('tasks');
|
||||
const [counts, setCounts] = useState<Counts>({ running: 0, runningJobId: null, queued: 0 });
|
||||
|
||||
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;
|
||||
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();
|
||||
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';
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { RotateCw } from 'lucide-react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useCapabilities } from 'hooks/useCapabilities';
|
||||
|
||||
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() {
|
||||
const client = useClient();
|
||||
const qc = useQueryClient();
|
||||
const { can } = useCapabilities();
|
||||
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 () => {
|
||||
if (loading) return;
|
||||
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 { Button } from '@/components/ui/button';
|
||||
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 = {
|
||||
label: string;
|
||||
@@ -18,14 +19,32 @@ type DockPillProps = {
|
||||
};
|
||||
|
||||
const DockPill = ({
|
||||
label, path, color, visible, onAction, onDragStart, onDropOnPill, dropIndicator, onDragOverPill, onDragLeavePill,
|
||||
label,
|
||||
path,
|
||||
color,
|
||||
visible,
|
||||
onAction,
|
||||
onDragStart,
|
||||
onDropOnPill,
|
||||
dropIndicator,
|
||||
onDragOverPill,
|
||||
onDragLeavePill,
|
||||
}: DockPillProps) => (
|
||||
<span
|
||||
draggable
|
||||
onDragStart={(ev) => onDragStart(ev, path)}
|
||||
onDragOver={onDragOverPill}
|
||||
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' : ''}`}
|
||||
>
|
||||
{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 (
|
||||
<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
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
@@ -83,7 +104,11 @@ const DropZone = ({ label, children, onDrop }: DropZoneProps) => {
|
||||
};
|
||||
|
||||
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 visiblePaths = new Set(items.map((i) => i.to));
|
||||
@@ -135,7 +160,10 @@ export const DockSettings = () => {
|
||||
|
||||
const handlePillDrop = useCallback(
|
||||
(draggedPath: string, targetPath: string) => {
|
||||
if (draggedPath === targetPath) { setDropTarget(null); return; }
|
||||
if (draggedPath === targetPath) {
|
||||
setDropTarget(null);
|
||||
return;
|
||||
}
|
||||
const side = dropTarget?.path === targetPath ? dropTarget.side : 'right';
|
||||
insertAt(draggedPath, targetPath, side);
|
||||
},
|
||||
@@ -163,7 +191,11 @@ export const DockSettings = () => {
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<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) => (
|
||||
<DockPill
|
||||
key={item.to}
|
||||
@@ -182,7 +214,9 @@ export const DockSettings = () => {
|
||||
</DropZone>
|
||||
|
||||
<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) => (
|
||||
<DockPill
|
||||
key={item.to}
|
||||
@@ -196,12 +230,7 @@ export const DockSettings = () => {
|
||||
))}
|
||||
</DropZone>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={reset}
|
||||
className="w-full h-9 text-sm cursor-pointer"
|
||||
>
|
||||
<Button 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" />
|
||||
Reset to defaults
|
||||
</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>
|
||||
);
|
||||
};
|
||||
+31
-29
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { Loader2, Lock } from 'lucide-react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { CAPABILITIES_QUERY_KEY } from 'hooks/useCapabilities';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -25,7 +25,9 @@ type CapabilityInfo = {
|
||||
type Grant = { role: string; capability: string; level: 'read' | 'write' };
|
||||
|
||||
type CapabilitiesResponse = {
|
||||
/** Grantable AND installed. What this server can currently do. */
|
||||
capabilities: CapabilityInfo[];
|
||||
|
||||
roles: string[];
|
||||
grants: Grant[];
|
||||
};
|
||||
@@ -97,21 +99,27 @@ export const PermissionsSection = () => {
|
||||
|
||||
return (
|
||||
<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 items-center gap-3">
|
||||
<span className="text-sm text-muted-foreground">Role</span>
|
||||
<Select value={activeRole ?? undefined} onValueChange={(value) => setRole(value)}>
|
||||
<SelectTrigger className="w-44">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<div className="flex items-center gap-1 rounded-lg border p-1" role="tablist" aria-label="Role">
|
||||
{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}
|
||||
</SelectItem>
|
||||
</button>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button onClick={save} disabled={!dirty || saving}>
|
||||
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
@@ -130,10 +138,11 @@ export const PermissionsSection = () => {
|
||||
const level = draft[capability.key] ?? 'none';
|
||||
return (
|
||||
<div key={capability.key} className="flex items-center justify-between gap-4 p-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium">{capability.label}</div>
|
||||
<div className="text-xs text-muted-foreground">{capability.description}</div>
|
||||
</div>
|
||||
{/* Label only. The descriptions went because with three rows called Terminal, Chat and Files
|
||||
they explained nothing anyone needed — and the "needs a Linux account" line went with them:
|
||||
every account gets one at creation, so warning about it on every row was noise about a state
|
||||
that no longer occurs on its own. */}
|
||||
<div className="min-w-0 text-sm font-medium">{capability.label}</div>
|
||||
<Select
|
||||
value={level}
|
||||
onValueChange={(value) => setDraft((prev) => ({ ...prev, [capability.key]: value as Level }))}
|
||||
@@ -152,19 +161,12 @@ export const PermissionsSection = () => {
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Stated rather than silently omitted. An owner who cannot find the Terminal checkbox will assume
|
||||
the screen is incomplete and go looking for it; saying why it does not exist is the difference
|
||||
between a deliberate design and a missing feature. */}
|
||||
<div className="flex gap-3 rounded-lg border border-dashed p-3 text-xs text-muted-foreground">
|
||||
<Lock className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<div>
|
||||
<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’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>
|
||||
{/* Two explanatory blocks used to sit here: one naming every capability whose sidecar is not installed,
|
||||
and one naming everything that can never be granted. Both are gone, and for the same reason — a
|
||||
server should not enumerate what it does not have. The first was a catalogue of uninstallable
|
||||
features presented as a permissions decision; the second described chat, tasks, the desktop and the
|
||||
wallet to an owner who may have none of them installed. What is on this screen is what this server
|
||||
can actually do. */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
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 { Button } from '@/components/ui/button';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { CreateUserForm } from './CreateUserForm';
|
||||
|
||||
type ManagedUser = {
|
||||
id: number;
|
||||
@@ -25,9 +26,20 @@ type ManagedUser = {
|
||||
role: string;
|
||||
createdAt: string;
|
||||
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'];
|
||||
|
||||
@@ -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) => {
|
||||
setPendingId(user.id);
|
||||
setConfirmDelete(null);
|
||||
@@ -86,9 +137,11 @@ export const UsersSection = () => {
|
||||
<div className="space-y-4">
|
||||
<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
|
||||
row cannot be changed from here.
|
||||
row cannot be changed from here, and no other account can be promoted into it.
|
||||
</p>
|
||||
|
||||
<CreateUserForm roles={data.assignableRoles} usersKey={USERS_KEY} />
|
||||
|
||||
<div className="rounded-lg border divide-y">
|
||||
{data.users.map((user) => {
|
||||
const busy = pendingId === user.id;
|
||||
@@ -102,6 +155,9 @@ export const UsersSection = () => {
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{user.email}
|
||||
{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>
|
||||
|
||||
@@ -114,7 +170,9 @@ export const UsersSection = () => {
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<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}>
|
||||
{role}
|
||||
</SelectItem>
|
||||
@@ -122,6 +180,45 @@ export const UsersSection = () => {
|
||||
</SelectContent>
|
||||
</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
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export * from './AppStore';
|
||||
export * from './Layout';
|
||||
export * from './Home';
|
||||
export * from './PasskeyGate';
|
||||
export * from './Plans';
|
||||
export * from './Processes';
|
||||
export * from './CapabilityPage';
|
||||
export * from './Settings';
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { usePlans } from 'state/usePlans';
|
||||
import { useSettings } from 'state/useSettings';
|
||||
import { useModels } from 'state/useModels';
|
||||
import { useAccessPolicy } from 'state/useAccessPolicy';
|
||||
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 = () => {
|
||||
const { plans } = usePlans();
|
||||
const { settings } = useSettings();
|
||||
useModels();
|
||||
useAccessPolicy();
|
||||
useColorModeSync();
|
||||
|
||||
return { plans, settings };
|
||||
return { settings };
|
||||
};
|
||||
|
||||
@@ -21,6 +21,7 @@ const RULES: TitleRule[] = [
|
||||
{ match: (p) => p.startsWith('/calendar'), title: 'Calendar' },
|
||||
{ match: (p) => p.startsWith('/contacts'), title: 'Contacts' },
|
||||
{ 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('/jellyfin'), title: 'Video' },
|
||||
{ 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('/browser'), title: 'Browser' },
|
||||
{ match: (p) => p.startsWith('/desktop'), title: 'Desktop' },
|
||||
{ match: (p) => p.startsWith('/plans'), title: 'Plans' },
|
||||
];
|
||||
|
||||
export function titleForPath(pathname: string): string {
|
||||
|
||||
@@ -2,6 +2,7 @@ export {
|
||||
getUsers,
|
||||
getUserById,
|
||||
getUserByEmail,
|
||||
getUserByUsername,
|
||||
getOwnerUser,
|
||||
getUserCount,
|
||||
createUser,
|
||||
@@ -280,3 +281,17 @@ export {
|
||||
markPushDeviceSeen,
|
||||
} from './queries/notify';
|
||||
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';
|
||||
|
||||
@@ -19,6 +19,14 @@ export async function getUserByEmail(email: string): Promise<UserSelect | undefi
|
||||
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
|
||||
// agent sidecar, which PM2 starts with no email in its env) resolve it here rather than being told by
|
||||
// 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
|
||||
// #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.
|
||||
//
|
||||
// `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> {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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)],
|
||||
);
|
||||
@@ -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';
|
||||
|
||||
// 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'),
|
||||
name: text('name'),
|
||||
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'),
|
||||
passwordChangedAt: timestamp('password_changed_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(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
|
||||
// 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.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './app-store';
|
||||
export * from './agent-panels';
|
||||
export * from './api-keys';
|
||||
export * from './auth';
|
||||
|
||||
+52
-1
@@ -3,9 +3,11 @@ import type { ServerWebSocket } from 'bun';
|
||||
import { serve } from 'bun';
|
||||
import { honoServer, PROTECTED_API_PREFIXES, UNPROTECTED_API_PREFIXES } from './servers/hono';
|
||||
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 { isWsProviderAllowed } from './servers/capabilities/authorize';
|
||||
import { isTokenBlacklisted } from 'officerdb';
|
||||
import { isTokenBlacklisted, getUserById } from 'officerdb';
|
||||
import { terminalWebsocket } from './servers/api/terminal/websocket';
|
||||
import { chatWebsocket } from './servers/api/chat/websocket';
|
||||
import { taskRunnerWebsocket } from './servers/api/tasks/task-executor';
|
||||
@@ -149,6 +151,11 @@ assertCapabilityTotality({
|
||||
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(
|
||||
req: Request,
|
||||
server: any,
|
||||
@@ -187,6 +194,36 @@ async function upgradeWs(
|
||||
const command = url.searchParams.get('command') ?? 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;
|
||||
|
||||
// ── 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, {
|
||||
data: {
|
||||
userId: user.id,
|
||||
@@ -221,6 +258,20 @@ const server = serve({
|
||||
const file = Bun.file(`public${new URL(req.url).pathname}`);
|
||||
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);
|
||||
// everything else on this path (SignalR long-poll negotiate/poll) falls through to the HTTP proxy.
|
||||
'/api/vault/notifications/*': (req, server) => {
|
||||
|
||||
@@ -49,18 +49,30 @@ export const userMiddleware: MiddlewareHandler = async function (ctx, next) {
|
||||
if (await isTokenBlacklisted(user.jti)) throw errors.UNAUTHORIZED();
|
||||
}
|
||||
|
||||
// Check if token was issued before password change
|
||||
if (user.iat && user.id) {
|
||||
// The account still has to exist, and still has to be allowed in.
|
||||
//
|
||||
// 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);
|
||||
if (dbUser?.passwordChangedAt) {
|
||||
// iat is in seconds, passwordChangedAt is a Date
|
||||
const tokenIssuedAt = user.iat * 1000;
|
||||
if (tokenIssuedAt < dbUser.passwordChangedAt.getTime()) {
|
||||
if (!dbUser) throw errors.UNAUTHORIZED();
|
||||
if (dbUser.status !== 'Active') throw errors.UNAUTHORIZED();
|
||||
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx.set('user', user);
|
||||
return next();
|
||||
|
||||
@@ -2,6 +2,7 @@ import { getUserById, markAgentPanelIntroduced, type AgentPanel } from 'officerd
|
||||
import * as sidecar from '@@/sidecar-registry';
|
||||
import { resolveBaseCwd } from '../chat/websocket';
|
||||
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.
|
||||
@@ -33,7 +34,7 @@ export async function deliverToAgentPanel(target: AgentPanel, prompt: string): P
|
||||
username: user.name ?? user.email,
|
||||
prompt,
|
||||
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
|
||||
// panel started with. Passing one here would only look like it worked.
|
||||
durable: true,
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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.
|
||||
*/
|
||||
function titleRun(
|
||||
email: string,
|
||||
who: { email: string; home: string },
|
||||
cwd: string,
|
||||
claudeSessionId: string,
|
||||
agentName: string,
|
||||
@@ -99,7 +99,7 @@ function titleRun(
|
||||
const title = [agentName, subject, when].filter(Boolean).join(' · ');
|
||||
|
||||
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 });
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -171,7 +171,13 @@ export async function startAgentRun(params: StartAgentRunParams): Promise<StartA
|
||||
run.finishedAt = Date.now();
|
||||
if (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') {
|
||||
run.status = 'failed';
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
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 * as errors from '@@/custom-errors';
|
||||
import { rememberUser } from '@@/_middlewares';
|
||||
@@ -44,6 +45,23 @@ export const bootstrapHandler: Handler = async function (ctx) {
|
||||
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
|
||||
// very first sign-in would be filed as an unknown identity.
|
||||
rememberUser(user);
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { Context } from 'hono';
|
||||
import { createRouter } from '../../create-router';
|
||||
import * as errors from '@@/custom-errors';
|
||||
import * as sidecar from '@@/sidecar-registry';
|
||||
import { getUserSettings } from 'officerdb';
|
||||
import {
|
||||
getGeneralChatSessionsCwd,
|
||||
listClaudePwds,
|
||||
listClaudeSessions,
|
||||
loadClaudeSession,
|
||||
@@ -24,30 +24,69 @@ import {
|
||||
import { getOpenCodePrompt, getOpenCodeSession } from './opencode/state';
|
||||
import { listChatModels } from './list-models';
|
||||
import { logger } from './logger';
|
||||
import { resolveHomeDir } from '@@/user-home';
|
||||
import type { ChatIdentity } from './claude-sessions';
|
||||
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 { registerAgentPanelRoutes } from './agent-panels-routes';
|
||||
|
||||
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
|
||||
// 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
|
||||
// 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.
|
||||
chatRouter.get('/pwds', (ctx) => {
|
||||
const email = ctx.get('user').email;
|
||||
return ctx.json({ pwds: listClaudePwds(email), default: getGeneralChatSessionsCwd(email) });
|
||||
chatRouter.get('/pwds', async (ctx) => {
|
||||
const who = await chatIdentity(ctx.get('user'));
|
||||
return ctx.json({ pwds: listClaudePwds(who), default: who.home });
|
||||
});
|
||||
|
||||
// GET /chat/sessions[?cwd=] — conversations for a working directory, merged across both harnesses
|
||||
// (Claude transcripts + OpenCode's session store), newest first.
|
||||
chatRouter.get('/sessions', async (ctx) => {
|
||||
const email = ctx.get('user').email;
|
||||
const cwd = cwdOf(ctx, email);
|
||||
const claude = listClaudeSessions(email, cwd).map((s) => ({ ...s, harness: 'claude' as const }));
|
||||
const who = await chatIdentity(ctx.get('user'));
|
||||
const cwd = cwdOf(ctx, who.home);
|
||||
const claude = listClaudeSessions(who, cwd).map((s) => ({ ...s, harness: 'claude' as const }));
|
||||
const opencode = await listOpenCodeSessions(cwd);
|
||||
const sessions = [...claude, ...opencode].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||||
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
|
||||
// where the window sits and whether older messages remain above it.
|
||||
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 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
|
||||
// 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)
|
||||
? 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);
|
||||
|
||||
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
|
||||
// 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.
|
||||
const context = isOpenCodeSessionId(id) ? null : claudeSessionContext(email, detail.cwd, id);
|
||||
const context = isOpenCodeSessionId(id) ? null : claudeSessionContext(who, detail.cwd, id);
|
||||
|
||||
return ctx.json({
|
||||
...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
|
||||
// happens to be browsing — which is how the list ended up showing raw ids for anything running elsewhere.
|
||||
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 registry calls swallow their errors and return [].
|
||||
const [live, liveOpenCode] = await Promise.all([
|
||||
sidecar.listLiveClaudeSessions(),
|
||||
sidecar.listLiveClaudeSessions(user.id),
|
||||
sidecar.listLiveOpenCodeSessions(),
|
||||
]);
|
||||
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
|
||||
// genuinely not been written yet.
|
||||
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 };
|
||||
});
|
||||
|
||||
@@ -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
|
||||
// deleting it deletes one conversation.
|
||||
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 cwd = cwdOf(ctx, email);
|
||||
const ok = isOpenCodeSessionId(id) ? await deleteOpenCodeSession(id) : deleteClaudeSession(email, cwd, id);
|
||||
const cwd = cwdOf(ctx, who.home);
|
||||
const ok = isOpenCodeSessionId(id) ? await deleteOpenCodeSession(id) : deleteClaudeSession(who, cwd, id);
|
||||
if (!ok) return ctx.text('Not found', 404);
|
||||
return ctx.json({ ok: true });
|
||||
});
|
||||
|
||||
// PATCH /chat/sessions/:id/title[?cwd=] — rename in the owning harness's store.
|
||||
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 cwd = cwdOf(ctx, email);
|
||||
const cwd = cwdOf(ctx, who.home);
|
||||
const { title } = await ctx.req.json<{ title?: string }>();
|
||||
if (!title?.trim()) return ctx.text('title is required', 400);
|
||||
const ok = isOpenCodeSessionId(id)
|
||||
? 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);
|
||||
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
|
||||
// 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.
|
||||
chatRouter.get('/tasks/:id', (ctx) => {
|
||||
const email = ctx.get('user').email;
|
||||
const detail = loadBackgroundTask(email, ctx.req.param('id'));
|
||||
chatRouter.get('/tasks/:id', async (ctx) => {
|
||||
const who = await chatIdentity(ctx.get('user'));
|
||||
const detail = loadBackgroundTask(who, ctx.req.param('id'));
|
||||
return ctx.json(detail ?? { kind: 'pending' });
|
||||
});
|
||||
|
||||
|
||||
@@ -18,23 +18,32 @@ import { DATA_PATH } from '../../data-path';
|
||||
// The `claude` CLI persists every session as a JSONL transcript at
|
||||
// $HOME/.claude/projects/<slug>/<session-uuid>.jsonl
|
||||
// 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
|
||||
// (HOME_DIR) — so its transcripts are the same store the terminal `claude` uses. We never keep our
|
||||
// own copy; Claude's files are authoritative.
|
||||
|
||||
const claudeHome = (email: string): string => process.env.HOME_DIR ?? join(DATA_PATH, email, 'home');
|
||||
|
||||
/** Dedicated working directory for /chat sessions, so they form their own Claude "project" group. */
|
||||
export const getGeneralChatSessionsCwd = (email: string): string => join(DATA_PATH, email, 'general_chat_sessions');
|
||||
|
||||
/** Same, but create the directory if it doesn't exist (call before spawning a /chat session). */
|
||||
export const ensureGeneralChatSessionsCwd = (email: string): string => {
|
||||
const dir = getGeneralChatSessionsCwd(email);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
// Claude runs with no isolation for the OWNER — HOME is their real home — so its transcripts are the same
|
||||
// store their terminal `claude` uses. We never keep our own copy; Claude's files are authoritative.
|
||||
//
|
||||
// ── Why this takes a home instead of an email ──
|
||||
//
|
||||
// It used to be `process.env.HOME_DIR ?? join(DATA_PATH, email, 'home')`, which discards its argument whenever
|
||||
// HOME_DIR is set — which is always, on a real install. Every read therefore resolved to the OWNER'S
|
||||
// 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
|
||||
// owner's conversation list.
|
||||
//
|
||||
// So the home arrives resolved, from `resolveHomeDir(userId)`, and this file has no way to invent one.
|
||||
//
|
||||
// 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. */
|
||||
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
|
||||
* 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 group = scanGroup(email, detail.cwd);
|
||||
const group = scanGroup(who, detail.cwd);
|
||||
const head = group.find((session) => session.id === detail.id);
|
||||
return head ? chainOf(head, new Map(group.map((session) => [session.id, session]))) : [];
|
||||
})();
|
||||
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[] = [];
|
||||
for (const part of parts.slice(0, -1)) {
|
||||
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. */
|
||||
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(
|
||||
join(claudeProjectsDir(email), projectSlug(cwd), `${sessionId}.jsonl`),
|
||||
join(claudeProjectsDir(who.home), projectSlug(cwd), `${sessionId}.jsonl`),
|
||||
sessionId,
|
||||
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 /
|
||||
* 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. */
|
||||
export function loadClaudeSessionById(email: string, sessionId: string): ClaudeSessionDetail | null {
|
||||
const projectsDir = claudeProjectsDir(email);
|
||||
export function loadClaudeSessionById(who: ChatIdentity, sessionId: string): ClaudeSessionDetail | null {
|
||||
const projectsDir = claudeProjectsDir(who.home);
|
||||
let slugs: string[];
|
||||
try {
|
||||
slugs = readdirSync(projectsDir);
|
||||
@@ -550,7 +559,7 @@ export function loadClaudeSessionById(email: string, sessionId: string): ClaudeS
|
||||
const filePath = join(projectsDir, slug, `${sessionId}.jsonl`);
|
||||
if (!existsSync(filePath)) continue;
|
||||
const detail = parseClaudeTranscript(filePath, sessionId);
|
||||
return detail && loadChainTranscript(email, detail);
|
||||
return detail && loadChainTranscript(who, detail);
|
||||
}
|
||||
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
|
||||
* 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 {
|
||||
const preferred = join(claudeProjectsDir(email), projectSlug(cwd), `${sessionId}.jsonl`);
|
||||
function findTranscript(who: ChatIdentity, cwd: string, sessionId: string): string | null {
|
||||
const preferred = join(claudeProjectsDir(who.home), projectSlug(cwd), `${sessionId}.jsonl`);
|
||||
if (existsSync(preferred)) return preferred;
|
||||
|
||||
const projectsDir = claudeProjectsDir(email);
|
||||
const projectsDir = claudeProjectsDir(who.home);
|
||||
let slugs: string[];
|
||||
try {
|
||||
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
|
||||
* part of several.
|
||||
*/
|
||||
export function deleteClaudeSession(email: string, cwd: string, sessionId: string): boolean {
|
||||
const filePath = findTranscript(email, cwd, sessionId);
|
||||
export function deleteClaudeSession(who: ChatIdentity, cwd: string, sessionId: string): boolean {
|
||||
const filePath = findTranscript(who, cwd, sessionId);
|
||||
if (!filePath) return false;
|
||||
|
||||
const ownCwd = firstCwd(filePath);
|
||||
const ids = ownCwd ? chainFileIds(email, ownCwd, sessionId) : [sessionId];
|
||||
const ids = ownCwd ? chainFileIds(who, ownCwd, sessionId) : [sessionId];
|
||||
const dir = dirname(filePath);
|
||||
for (const id of ids) {
|
||||
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
|
||||
* timestamp is written so the rename doesn't reorder the list.
|
||||
*/
|
||||
export function renameClaudeSession(email: string, cwd: string, sessionId: string, title: string): boolean {
|
||||
const filePath = findTranscript(email, cwd, sessionId);
|
||||
export function renameClaudeSession(who: ChatIdentity, cwd: string, sessionId: string, title: string): boolean {
|
||||
const filePath = findTranscript(who, cwd, sessionId);
|
||||
if (!filePath) return false;
|
||||
|
||||
// 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: '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 candidates: [string, string][] = [
|
||||
[tmpRoot, `claude-*/*/*/tasks/${taskId}.output`],
|
||||
[claudeProjectsDir(email), `*/*/subagents/agent-${taskId}.jsonl`],
|
||||
[claudeProjectsDir(who.home), `*/*/subagents/agent-${taskId}.jsonl`],
|
||||
];
|
||||
for (const [root, pattern] of candidates) {
|
||||
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
|
||||
* 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;
|
||||
const found = findTaskOutput(email, taskId);
|
||||
const found = findTaskOutput(who, taskId);
|
||||
if (!found) return null;
|
||||
|
||||
let target = found;
|
||||
@@ -753,10 +762,10 @@ function firstCwd(filePath: string): string {
|
||||
|
||||
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. */
|
||||
export function listClaudePwds(email: string): ClaudePwd[] {
|
||||
const projectsDir = claudeProjectsDir(email);
|
||||
const defaultCwd = getGeneralChatSessionsCwd(email);
|
||||
/** All working directories that have Claude sessions, plus the caller's home. Newest first. */
|
||||
export function listClaudePwds(who: ChatIdentity): ClaudePwd[] {
|
||||
const projectsDir = claudeProjectsDir(who.home);
|
||||
const defaultCwd = who.home;
|
||||
const byCwd = new Map<string, { count: number; updatedAt: string }>();
|
||||
|
||||
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
|
||||
* mtime-cached, which is what makes calling this on every request cheap.
|
||||
*/
|
||||
function scanGroup(email: string, cwd: string): TranscriptSummary[] {
|
||||
const dir = join(claudeProjectsDir(email), projectSlug(cwd));
|
||||
function scanGroup(who: ChatIdentity, cwd: string): TranscriptSummary[] {
|
||||
const dir = join(claudeProjectsDir(who.home), projectSlug(cwd));
|
||||
if (!existsSync(dir)) return [];
|
||||
|
||||
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. */
|
||||
export function listClaudeSessions(email: string, cwd: string): ClaudeSessionSummary[] {
|
||||
return mergeChains(scanGroup(email, cwd))
|
||||
export function listClaudeSessions(who: ChatIdentity, cwd: string): ClaudeSessionSummary[] {
|
||||
return mergeChains(scanGroup(who, cwd))
|
||||
.map(publish)
|
||||
.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.
|
||||
*/
|
||||
export function claudeSessionContext(
|
||||
email: string,
|
||||
who: ChatIdentity,
|
||||
cwd: string,
|
||||
sessionId: string,
|
||||
): { 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 };
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
* anywhere hotter.
|
||||
*/
|
||||
export function liveSessionTitle(email: string, sessionId: string): { title: string; cwd: string } | null {
|
||||
const projectsDir = claudeProjectsDir(email);
|
||||
export function liveSessionTitle(who: ChatIdentity, sessionId: string): { title: string; cwd: string } | null {
|
||||
const projectsDir = claudeProjectsDir(who.home);
|
||||
let slugs: string[];
|
||||
try {
|
||||
slugs = readdirSync(projectsDir);
|
||||
@@ -884,7 +893,7 @@ export function liveSessionTitle(email: string, sessionId: string): { title: str
|
||||
}
|
||||
if (!cwd) return null;
|
||||
|
||||
const context = claudeSessionContext(email, cwd, sessionId);
|
||||
const context = claudeSessionContext(who, cwd, sessionId);
|
||||
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
|
||||
* reads as the delete having half worked.
|
||||
*/
|
||||
function chainFileIds(email: string, cwd: string, sessionId: string): string[] {
|
||||
const group = scanGroup(email, cwd);
|
||||
function chainFileIds(who: ChatIdentity, cwd: string, sessionId: string): string[] {
|
||||
const group = scanGroup(who, cwd);
|
||||
const head = group.find((session) => session.id === sessionId);
|
||||
if (!head) return [sessionId];
|
||||
return chainOf(head, new Map(group.map((session) => [session.id, session]))).map((part) => part.id);
|
||||
|
||||
@@ -13,11 +13,12 @@ import { sessionManager } from './session-manager';
|
||||
import { rememberOpenCodePrompt } from './opencode/state';
|
||||
import { sendClaudeCodeStreaming } from '@@/channels/send-claude-code';
|
||||
import { sendOpenCodeStreaming } from '@@/channels/send-opencode';
|
||||
import { ensureGeneralChatSessionsCwd } from './claude-sessions';
|
||||
import * as sidecar from '@@/sidecar-registry';
|
||||
import { join } from '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 { logger } from './logger';
|
||||
|
||||
@@ -28,6 +29,40 @@ const DEFAULT_MODEL = 'claude-code';
|
||||
// (opencode/anthropic/openai/… — all `providerID/modelID` ids) runs through the OpenCode server.
|
||||
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> {
|
||||
try {
|
||||
const settings = await getUserSettings(userId);
|
||||
@@ -48,16 +83,33 @@ type WSData = {
|
||||
|
||||
const IDLE_TIMEOUT_MS = 60 * 60 * 1000; // 1 hour
|
||||
|
||||
const resolveCwd = (email: string, cwd?: string) => {
|
||||
const root = getOwnerHomeDir(email);
|
||||
if (!cwd || cwd === '~') return root;
|
||||
if (cwd.startsWith('~/')) return join(root, cwd.slice(2));
|
||||
// The server owner is the only account — absolute paths are theirs to use.
|
||||
/**
|
||||
* Where a turn runs, relative to the caller's own home.
|
||||
*
|
||||
* `root` used to be `getOwnerHomeDir(email)`, which ignores its argument whenever HOME_DIR is set — so every
|
||||
* `~` 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;
|
||||
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:
|
||||
// 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
|
||||
// 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(
|
||||
msg: { context?: string; contextId?: string; cwd?: string },
|
||||
email: string,
|
||||
userId: number,
|
||||
home: string,
|
||||
): Promise<string> {
|
||||
if (msg.context === 'email') return resolveEmailCwd(userId, email, msg.contextId);
|
||||
if (msg.context === 'chat') return msg.cwd?.trim() ? resolveCwd(email, msg.cwd) : ensureGeneralChatSessionsCwd(email);
|
||||
return resolveCwd(email, msg.cwd);
|
||||
if (msg.context === 'chat') {
|
||||
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>();
|
||||
@@ -307,7 +375,41 @@ async function handleClaudeCodeChat(
|
||||
): Promise<void> {
|
||||
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;
|
||||
|
||||
@@ -359,6 +461,7 @@ async function handleClaudeCodeChat(
|
||||
sessionKey: sessionId,
|
||||
cwd,
|
||||
model,
|
||||
member: identity.kind === 'member' ? identity.run : undefined,
|
||||
resumeSessionId: msg.resumeSessionId,
|
||||
onMessage,
|
||||
});
|
||||
@@ -408,7 +511,9 @@ async function handleOpenCodeChat(
|
||||
): Promise<void> {
|
||||
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.
|
||||
rememberOpenCodePrompt(sessionId, msg.displayText || msg.prompt);
|
||||
@@ -537,7 +642,7 @@ async function handleStop(ws: ServerWebSocket<WSData>): Promise<void> {
|
||||
if (isClaudeModel(session.model)) {
|
||||
// Interrupt the current turn but KEEP the persistent session alive (background tasks + the
|
||||
// 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 });
|
||||
} else {
|
||||
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
|
||||
// this session" and opens a *second* subscription, which would then deliver every message twice.
|
||||
session._claudeKill = () => {
|
||||
if (isClaudeModel(model)) sidecar.killClaude(sessionId);
|
||||
if (isClaudeModel(model)) sidecar.killClaude(sessionId, userId);
|
||||
else sidecar.killOpenCode(sessionId);
|
||||
unsub();
|
||||
};
|
||||
@@ -653,7 +758,7 @@ async function handleResumeCursor(
|
||||
// the Claude sidecar about an OpenCode session, hear "not generating", and write "the agent went
|
||||
// away" into a turn that was running perfectly well.
|
||||
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;
|
||||
if (!claudeSessionId) return;
|
||||
|
||||
const sessionId = await sidecar.findClaudeSessionKey(claudeSessionId);
|
||||
const sessionId = await sidecar.findClaudeSessionKey(claudeSessionId, ws.data.userId);
|
||||
if (!sessionId) {
|
||||
// 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
|
||||
@@ -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
|
||||
// an adopted session it is a fresh record's default, so ask the agent — the same question, and for the
|
||||
// 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;
|
||||
|
||||
// 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>,
|
||||
sessionId: string,
|
||||
model: string,
|
||||
userId: number,
|
||||
): Promise<void> {
|
||||
if (!isClaudeModel(model)) return;
|
||||
if (await sidecar.isClaudeGenerating(sessionId)) return;
|
||||
if (await sidecar.isClaudeGenerating(sessionId, userId)) return;
|
||||
|
||||
const session = sessionManager.getSession(sessionId);
|
||||
if (session) session.isGenerating = false;
|
||||
@@ -852,10 +958,16 @@ async function endTurnIfAgentIsGone(
|
||||
sidecar.onClaudeSidecarStarted(() => {
|
||||
for (const session of sessionManager.getAllSessions()) {
|
||||
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(
|
||||
session.sockets as Set<ServerWebSocket<WSData>>,
|
||||
session.sessionId,
|
||||
session.model,
|
||||
session.userId,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 { existsSync } from 'node:fs';
|
||||
import { getOwnerHomeDir, DATA_PATH } from '@@/data-path';
|
||||
import { resolveHomeDir } from '@@/user-home';
|
||||
import * as errors from '@@/custom-errors';
|
||||
import { readTtsConfig } from '@@/api/server-settings/tts';
|
||||
import { readSttConfig } from '@@/api/server-settings/stt';
|
||||
@@ -18,7 +19,6 @@ async function getUserTtsVoice(userId: number): Promise<string | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
const DEFAULT_HOME_DIRS = ['Downloads', 'Documents', 'Music', 'Videos', 'Pictures'];
|
||||
const OLD_CACHE_DIRS = ['ocr', 'tts', 'transcriptions', 'audio', 'video'];
|
||||
|
||||
async function cleanOldCacheDirs(userDataDir: string) {
|
||||
@@ -28,23 +28,51 @@ async function cleanOldCacheDirs(userDataDir: string) {
|
||||
}
|
||||
}
|
||||
|
||||
async function seedHomeDir(homeDir: string) {
|
||||
for (const dir of DEFAULT_HOME_DIRS) {
|
||||
const target = join(homeDir, dir);
|
||||
if (!existsSync(target)) await mkdir(target, { recursive: true });
|
||||
}
|
||||
}
|
||||
// `seedHomeDir` used to be here, creating Downloads/Documents/Music/Videos/Pictures on the first listing of
|
||||
// any home. Removed 2026-08-11: it invented folders in somebody's home directory as a side effect of LOOKING
|
||||
// at it, which is not a listing's business and not a layout the platform has any standing to choose.
|
||||
|
||||
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 {
|
||||
return join(DATA_PATH, email);
|
||||
}
|
||||
|
||||
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);
|
||||
throw errors.BAD_REQUEST(`Invalid root: ${root}`);
|
||||
}
|
||||
@@ -140,10 +168,17 @@ router.get('/ls', async (ctx) => {
|
||||
const relPath = (ctx.req.query('path') || '/').replace(/^\/+/, '');
|
||||
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') {
|
||||
await seedHomeDir(rootDir);
|
||||
try {
|
||||
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)
|
||||
@@ -154,7 +189,19 @@ router.get('/ls', async (ctx) => {
|
||||
let names: string[];
|
||||
try {
|
||||
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 });
|
||||
}
|
||||
const entries = await Promise.all(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -5,7 +5,7 @@ import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { getUserSettings } from 'officerdb';
|
||||
import { getTaskByDirName } from './task-files';
|
||||
import { getHomeDir } from '../../data-path';
|
||||
import { getHomeDir, getOwnerHomeDir } from '../../data-path';
|
||||
import { resolveBaseCwd } from '../chat/websocket';
|
||||
import { sendClaudeCodeStreaming } from '../../channels/send-claude-code';
|
||||
import type { TurnMessage, MessageCost } from '../chat/types';
|
||||
@@ -496,7 +496,7 @@ async function runForeach({
|
||||
}
|
||||
|
||||
const cwdRelative = cwd ? `${cwd}/${subdir}` : subdir;
|
||||
const resolvedCwd = resolveBaseCwd(email, cwdRelative);
|
||||
const resolvedCwd = resolveBaseCwd(getOwnerHomeDir(email), cwdRelative);
|
||||
const targetDir = resolvedCwd;
|
||||
const prompt = buildStepPrompt(stepTask.body!, iterInputs, targetDir);
|
||||
|
||||
@@ -588,7 +588,7 @@ export async function executePipeline({
|
||||
return;
|
||||
}
|
||||
|
||||
const baseCwd = resolveBaseCwd(email, cwd);
|
||||
const baseCwd = resolveBaseCwd(getOwnerHomeDir(email), cwd);
|
||||
let model = modelOverride || (await resolveModel(userId));
|
||||
// Claude-only: coerce any legacy non-Claude task-model preference to the Claude default.
|
||||
if (!model.startsWith('claude-code')) model = DEFAULT_MODEL;
|
||||
|
||||
@@ -6,6 +6,7 @@ import { getAllRoleGrants, replaceRoleGrants, USER_ROLES } from 'officerdb';
|
||||
import type { UserRole } from 'officerdb';
|
||||
import { CAPABILITIES, GRANTABLE_CAPABILITIES, CAPABILITY_BY_KEY } from '../../capabilities/registry';
|
||||
import { getEffectiveCapabilities, invalidateRoleGrants } from '../../capabilities/authorize';
|
||||
import { capabilityAvailability } from '../../app-store/availability';
|
||||
|
||||
// Two audiences, deliberately split.
|
||||
//
|
||||
@@ -28,6 +29,11 @@ selfCapabilitiesRouter.get('/capabilities', async (ctx) => {
|
||||
const userId = ctx.get('user').id as number;
|
||||
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
|
||||
// remember to special-case. One shape for both audiences means one code path in the UI.
|
||||
const held = isOwner
|
||||
@@ -35,16 +41,34 @@ selfCapabilitiesRouter.get('/capabilities', async (ctx) => {
|
||||
: [...grants].map(([key, level]) => ({ key, level }));
|
||||
|
||||
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({
|
||||
isOwner,
|
||||
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.
|
||||
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
|
||||
// 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.
|
||||
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();
|
||||
|
||||
capabilityAdminRouter.get('/capabilities', ownerGate, async (ctx) => {
|
||||
return ctx.json({
|
||||
// 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.
|
||||
capabilities: GRANTABLE_CAPABILITIES.map((c) => ({
|
||||
// Only what this server can actually do RIGHT NOW.
|
||||
//
|
||||
// The same subtraction the dock already makes, applied to the granting UI — which was showing all
|
||||
// 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,
|
||||
label: c.label,
|
||||
description: c.description,
|
||||
// What the owner is actually deciding about, shown so the grant is legible rather than a name.
|
||||
routes: c.routes ?? [],
|
||||
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
|
||||
// database refuses a row for that role.
|
||||
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.
|
||||
const known = CAPABILITY_BY_KEY.get(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(
|
||||
known.kind === 'execution'
|
||||
? `${known.label} runs as the server owner and can never be granted`
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
};
|
||||
@@ -2,6 +2,7 @@ import type { Handler } from 'hono';
|
||||
import { getUsers, getUserById, updateUser, deleteUser, USER_ROLES, OWNER_USER_ID } from 'officerdb';
|
||||
import type { UserRole } from 'officerdb';
|
||||
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
|
||||
// users-router.ts; these handlers assume the caller is the Super Admin.
|
||||
@@ -19,9 +20,18 @@ type PublicUser = {
|
||||
createdAt: Date;
|
||||
/** True for the bootstrap account. The UI uses it to lock the row; the database enforces it. */
|
||||
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,
|
||||
email: u.email,
|
||||
name: u.name,
|
||||
@@ -31,14 +41,22 @@ const toPublicUser = (u: Awaited<ReturnType<typeof getUsers>>[number]): PublicUs
|
||||
role: u.role,
|
||||
createdAt: u.createdAt,
|
||||
isOwner: u.id === OWNER_USER_ID,
|
||||
osUser: u.osUser,
|
||||
osSshPublicKey: u.osSshPublicKey,
|
||||
});
|
||||
|
||||
export const listUsersHandler: Handler = async function (ctx) {
|
||||
const users = await getUsers();
|
||||
return ctx.json({
|
||||
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,
|
||||
assignableRoles: USER_ROLES.filter((r) => r !== 'Super Admin'),
|
||||
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.');
|
||||
}
|
||||
|
||||
// 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);
|
||||
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);
|
||||
};
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import { isSuperAdmin } from '@@/super-admin';
|
||||
import * as errors from '@@/custom-errors';
|
||||
import { updateUserHandler } from './update-user';
|
||||
import { listUsersHandler, updateUserRoleHandler, deleteUserHandler } from './manage-users';
|
||||
import { createUserHandler } from './create-user';
|
||||
import { provisionLinuxHandler } from './provision-linux-route';
|
||||
import { capabilityAdminRouter } from './capabilities-routes';
|
||||
|
||||
export const usersRouter = createRouter();
|
||||
@@ -24,7 +26,11 @@ const ownerGate: MiddlewareHandler = async (ctx, next) => {
|
||||
};
|
||||
|
||||
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.post('/:id/provision-linux', ownerGate, provisionLinuxHandler);
|
||||
usersRouter.delete('/:id', ownerGate, deleteUserHandler);
|
||||
|
||||
// Which capabilities each role holds. Owner-gated inside its own router.
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 machine’s 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));
|
||||
@@ -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']);
|
||||
@@ -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);
|
||||
@@ -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 step’s 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 caller’s 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');
|
||||
});
|
||||
});
|
||||
@@ -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' };
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
};
|
||||
@@ -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');
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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)"
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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}` };
|
||||
}
|
||||
@@ -76,11 +76,30 @@ export async function getEffectiveCapabilities(userId: number | undefined): Prom
|
||||
const grants = new Map<string, CapabilityLevel>();
|
||||
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)) {
|
||||
const capability = CAPABILITY_BY_KEY.get(key);
|
||||
// Unknown key: a capability that was renamed or removed while a grant survived. Ignore it — the
|
||||
// alternative is honouring a name nothing defines.
|
||||
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;
|
||||
grants.set(key, level);
|
||||
}
|
||||
@@ -145,6 +164,9 @@ export async function isWsProviderAllowed(userId: number | undefined, provider:
|
||||
if (isOwner) return true;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -154,12 +154,37 @@ describe('self-service routes', () => {
|
||||
describe('kinds', () => {
|
||||
test('execution capabilities are never grantable', () => {
|
||||
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(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', () => {
|
||||
const grantable = new Set(GRANTABLE_CAPABILITIES.map((c) => c.key));
|
||||
for (const key of ['user-admin', 'server-admin', 'wallet', 'headscale']) {
|
||||
|
||||
@@ -20,9 +20,26 @@
|
||||
// core every authenticated account, always. Not grantable because not deniable — signing in
|
||||
// without them means a broken app, not a restricted one.
|
||||
// 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.
|
||||
// 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
|
||||
// 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
|
||||
@@ -38,7 +55,7 @@
|
||||
// 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.
|
||||
|
||||
export type CapabilityKind = 'core' | 'app' | 'execution' | 'admin';
|
||||
export type CapabilityKind = 'core' | 'app' | 'confined' | 'execution' | 'admin';
|
||||
|
||||
export type Capability = {
|
||||
/** 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.
|
||||
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',
|
||||
label: 'Dashboards',
|
||||
description: 'Your own dashboards and saved layouts',
|
||||
kind: 'app',
|
||||
label: 'Screen layouts and dashboards',
|
||||
description: 'Where your own screen layouts and dashboards are saved',
|
||||
kind: 'core',
|
||||
api: ['/dashboards'],
|
||||
routes: ['/dashboards'],
|
||||
personal: ['/'],
|
||||
},
|
||||
{
|
||||
key: 'plans',
|
||||
label: 'Plans',
|
||||
description: 'Plan documents',
|
||||
kind: 'app',
|
||||
api: ['/plans'],
|
||||
routes: ['/plans'],
|
||||
},
|
||||
|
||||
// ── 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',
|
||||
label: 'Terminal',
|
||||
description: 'A real shell as the server owner',
|
||||
kind: 'execution',
|
||||
description: 'A shell on this machine, as your own user',
|
||||
kind: 'confined',
|
||||
api: ['/terminal'],
|
||||
ws: ['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',
|
||||
label: 'Chat',
|
||||
description: 'The agent, running unsandboxed as the server owner',
|
||||
kind: 'execution',
|
||||
api: ['/chat'],
|
||||
description: 'The agent',
|
||||
kind: 'confined',
|
||||
// `/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'],
|
||||
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',
|
||||
label: 'Files',
|
||||
description: "The server owner's filesystem, and the code editor over it",
|
||||
kind: 'execution',
|
||||
description: 'Your own home directory on this machine, and the code editor over it',
|
||||
kind: 'confined',
|
||||
api: ['/file-browser', '/upload'],
|
||||
routes: ['/files', '/code-editor'],
|
||||
},
|
||||
@@ -298,6 +337,18 @@ export const CAPABILITIES: Capability[] = [
|
||||
},
|
||||
|
||||
// ── 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',
|
||||
label: 'Server settings',
|
||||
@@ -339,8 +390,30 @@ export const CAPABILITIES: Capability[] = [
|
||||
|
||||
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. */
|
||||
export const CORE_CAPABILITIES = CAPABILITIES.filter((c) => c.kind === 'core');
|
||||
|
||||
@@ -18,8 +18,8 @@ type ClaudeCodeResult = {
|
||||
cost: MessageCost;
|
||||
};
|
||||
|
||||
export function clearClaudeCodeSession(sessionKey: string): void {
|
||||
sidecar.clearClaudeSession(sessionKey);
|
||||
export function clearClaudeCodeSession(sessionKey: string, userId: number): void {
|
||||
sidecar.clearClaudeSession(sessionKey, userId);
|
||||
}
|
||||
|
||||
export async function sendClaudeCode(params: ClaudeCodeParams): Promise<ClaudeCodeResult> {
|
||||
@@ -40,6 +40,8 @@ type ClaudeCodeStreamingParams = {
|
||||
model?: string;
|
||||
resumeSessionId?: string;
|
||||
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.
|
||||
onMessage: (msg: TurnMessage, seq?: number) => void;
|
||||
};
|
||||
@@ -75,7 +77,7 @@ export async function sendClaudeCodeStreaming(params: ClaudeCodeStreamingParams)
|
||||
|
||||
return {
|
||||
kill: () => {
|
||||
sidecar.killClaude(params.sessionKey);
|
||||
sidecar.killClaude(params.sessionKey, params.userId);
|
||||
unsub();
|
||||
},
|
||||
detach: unsub,
|
||||
|
||||
@@ -4,7 +4,16 @@ import { Hono } from 'hono';
|
||||
export type HonoVariables = {
|
||||
body: Record<string, unknown>;
|
||||
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 }>();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { join, resolve } from 'node:path';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { chmodSync, mkdirSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
|
||||
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.
|
||||
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');
|
||||
|
||||
|
||||
+4
-2
@@ -8,7 +8,6 @@ import { landingPageDataRouter } from './api/landing-page-data/landing-page-data
|
||||
import { waitlistRouter } from './api/waitlist/waitlist';
|
||||
import { usersRouter } from './api/users/users-router';
|
||||
import { apiKeysRouter } from './api/api-keys/router';
|
||||
import { plansRouter } from './api/plans/plans';
|
||||
import { skillsRouter } from './api/skills/skills';
|
||||
import { tasksRouter } from './api/tasks/tasks';
|
||||
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 { memosRouter } from './api/memos/router';
|
||||
import { giteaRouter } from './api/gitea/router';
|
||||
import { appStoreRouter } from './api/app-store/router';
|
||||
import { davSyncRouter } from './api/dav/sync-router';
|
||||
import { davRouter } from './api/dav/router';
|
||||
import { claimIosProfile } from './api/dav/ios-profile';
|
||||
@@ -53,6 +53,7 @@ import { emailRouter } from './api/email/router';
|
||||
import { browserRouter } from './api/browser/router';
|
||||
import { desktopRouter } from './api/desktop/rest';
|
||||
import { bugReportRouter } from './api/bug-report/bug-report';
|
||||
import { agentStatusRouter } from './api/agent-status/router';
|
||||
import { chatRouter } from './api/chat/chat';
|
||||
import { pipelineJobsRouter } from './api/tasks/pipeline-jobs-routes';
|
||||
import { CustomError } from './custom-errors';
|
||||
@@ -189,7 +190,6 @@ protectedRouter.use(userMiddleware);
|
||||
const PROTECTED_MOUNTS: [prefix: string, router: ReturnType<typeof createRouter>][] = [
|
||||
['/server-settings', serverSettingsRouter],
|
||||
['/users', usersRouter],
|
||||
['/plans', plansRouter],
|
||||
['/skills', skillsRouter],
|
||||
['/tasks', tasksRouter],
|
||||
['/agents', agentsRouter],
|
||||
@@ -208,6 +208,7 @@ const PROTECTED_MOUNTS: [prefix: string, router: ReturnType<typeof createRouter>
|
||||
['/terminal', terminalRouter],
|
||||
['/memos', memosRouter],
|
||||
['/gitea', giteaRouter],
|
||||
['/app-store', appStoreRouter],
|
||||
['/caldav', caldavRouter], // the JSON door for Officer's own calendar/contacts UI
|
||||
['/dav', davRouter], // app-password management (the sync door is /dav, top-level)
|
||||
['/notify', notifyRouter],
|
||||
@@ -226,6 +227,7 @@ const PROTECTED_MOUNTS: [prefix: string, router: ReturnType<typeof createRouter>
|
||||
['/email', emailRouter],
|
||||
['/browser', browserRouter],
|
||||
['/bug-report', bugReportRouter],
|
||||
['/agent-status', agentStatusRouter],
|
||||
['/chat', chatRouter],
|
||||
['/pipeline-jobs', pipelineJobsRouter],
|
||||
['/jobs', pipelineJobsRouter], // unified jobs API (script + pipeline); /pipeline-jobs kept for the existing UI
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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'),
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
@@ -288,13 +288,13 @@ export async function spawnClaudeStreaming(params: ClaudeSpawnStreamingParams):
|
||||
throw new Error('Unexpected response');
|
||||
}
|
||||
|
||||
export function killClaude(sessionKey: string): void {
|
||||
sendFire('claude', { type: 'claude:kill', id: nextId(), sessionKey });
|
||||
export function killClaude(sessionKey: string, userId: number): void {
|
||||
sendFire('claude', { type: 'claude:kill', id: nextId(), sessionKey, userId });
|
||||
}
|
||||
|
||||
// Interrupt the current turn but keep the persistent session warm (the "stop" button).
|
||||
export function interruptClaude(sessionKey: string): void {
|
||||
sendFire('claude', { type: 'claude:interrupt', id: nextId(), sessionKey });
|
||||
export function interruptClaude(sessionKey: string, userId: number): void {
|
||||
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
|
||||
* 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');
|
||||
if (!sc) return [];
|
||||
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 : [];
|
||||
} catch {
|
||||
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');
|
||||
if (!sc) return false;
|
||||
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;
|
||||
} catch {
|
||||
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
|
||||
* 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');
|
||||
if (!sc) return null;
|
||||
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;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function clearClaudeSession(sessionKey: string): void {
|
||||
sendFire('claude', { type: 'claude:clear-session', id: nextId(), sessionKey });
|
||||
export function clearClaudeSession(sessionKey: string, userId: number): void {
|
||||
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
|
||||
|
||||
@@ -6,6 +6,8 @@ import type { ChatEvent, PromptImage } from '../../api/chat/types';
|
||||
import type { ClaudeSpawnParams, ClaudeSpawnStreamingParams, ClaudeCodeResult, LiveClaudeSession } from '../protocol';
|
||||
import { setClaudeSession, clearClaudeSession, getClaudeSession } from './state';
|
||||
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;
|
||||
|
||||
@@ -59,7 +61,7 @@ type ClaudeCodeOutput = {
|
||||
export async function spawnClaude(params: ClaudeSpawnParams): Promise<ClaudeCodeResult> {
|
||||
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'];
|
||||
|
||||
@@ -118,7 +120,7 @@ export async function spawnClaude(params: ClaudeSpawnParams): Promise<ClaudeCode
|
||||
}
|
||||
|
||||
if (output.session_id) {
|
||||
setClaudeSession(sessionKey, output.session_id);
|
||||
setClaudeSession(sessionKey, output.session_id, params.userId);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -165,6 +167,8 @@ type SdkUserMessage = {
|
||||
|
||||
type PersistentSession = {
|
||||
sessionKey: string;
|
||||
/** Whose session this is. The map is global and `sessionKey` arrives in a client message. */
|
||||
userId: number;
|
||||
query: Query;
|
||||
pushTurn: (prompt: string, images?: PromptImage[]) => void;
|
||||
closeInput: () => void;
|
||||
@@ -290,7 +294,8 @@ function armIdle(session: PersistentSession): void {
|
||||
armIdle(session);
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -301,6 +306,7 @@ function createSession(params: ClaudeSpawnStreamingParams, onEvent: (event: Chat
|
||||
|
||||
const session: PersistentSession = {
|
||||
sessionKey,
|
||||
userId: params.userId,
|
||||
query: undefined as unknown as Query,
|
||||
pushTurn: () => {},
|
||||
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).
|
||||
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 q = query({
|
||||
prompt: input.gen as AsyncIterable<SdkUserMessage>,
|
||||
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',
|
||||
allowDangerouslySkipPermissions: true,
|
||||
includePartialMessages: true,
|
||||
@@ -347,7 +355,18 @@ function createSession(params: ClaudeSpawnStreamingParams, onEvent: (event: Chat
|
||||
],
|
||||
},
|
||||
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'],
|
||||
env: cleanEnv as Record<string, string>,
|
||||
stderr: (d: string) => {
|
||||
@@ -355,7 +374,15 @@ function createSession(params: ClaudeSpawnStreamingParams, onEvent: (event: Chat
|
||||
},
|
||||
...(subModel ? { model: subModel } : {}),
|
||||
...(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>>) {
|
||||
processMessage(msg, state, {
|
||||
onEvent: emit,
|
||||
onSessionId: (id: string) => setClaudeSession(sessionKey, id),
|
||||
onSessionId: (id: string) => setClaudeSession(sessionKey, id, params.userId),
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -450,6 +477,15 @@ export async function spawnClaudeStreaming(
|
||||
onEvent: (event: ChatEvent) => void,
|
||||
): Promise<void> {
|
||||
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) {
|
||||
session.emit = onEvent; // adopt the latest emitter (equivalent across turns; keeps events flowing)
|
||||
} else {
|
||||
@@ -459,8 +495,8 @@ export async function spawnClaudeStreaming(
|
||||
}
|
||||
|
||||
/** Interrupt the current turn but KEEP the session alive (the "stop" button). */
|
||||
export async function interruptClaudeSession(sessionKey: string): Promise<boolean> {
|
||||
const session = sessions.get(sessionKey);
|
||||
export async function interruptClaudeSession(sessionKey: string, userId: number): Promise<boolean> {
|
||||
const session = ownedSession(sessionKey, userId);
|
||||
if (!session) return false;
|
||||
// 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.
|
||||
@@ -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. */
|
||||
export function killClaudeSession(sessionKey: string): boolean {
|
||||
const session = sessions.get(sessionKey);
|
||||
export function killClaudeSession(sessionKey: string, userId: number): boolean {
|
||||
const session = ownedSession(sessionKey, userId);
|
||||
if (!session) return false;
|
||||
if (session.idleTimer) clearTimeout(session.idleTimer);
|
||||
if (session.stallTimer) clearTimeout(session.stallTimer);
|
||||
@@ -497,10 +533,26 @@ export function killClaudeSession(sessionKey: string): boolean {
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
@@ -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
|
||||
* caller has. These are the same two fields `armIdle` consults before collecting a session.
|
||||
*/
|
||||
export function listSessions(): LiveClaudeSession[] {
|
||||
return Array.from(sessions.values()).map((session) => ({
|
||||
export function listSessions(userId: number): LiveClaudeSession[] {
|
||||
// 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,
|
||||
// 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.
|
||||
claudeSessionId: getClaudeSession(session.sessionKey) ?? null,
|
||||
claudeSessionId: getClaudeSession(session.sessionKey, session.userId) ?? null,
|
||||
isGenerating: session.isGenerating,
|
||||
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
|
||||
* it was running is gone, however alive the client still believes it to be.
|
||||
*/
|
||||
export function isSessionGenerating(sessionKey: string): boolean {
|
||||
return sessions.get(sessionKey)?.isGenerating ?? false;
|
||||
export function isSessionGenerating(sessionKey: string, userId: number): boolean {
|
||||
// "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;
|
||||
};
|
||||
}
|
||||
@@ -3,9 +3,22 @@ import { mkdirSync, existsSync, readFileSync, writeFileSync, unlinkSync } from '
|
||||
|
||||
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 = {
|
||||
proxySecret: string;
|
||||
claudeSessions: Record<string, string>; // sessionKey → Claude Code session_id
|
||||
claudeSessions: Record<string, SessionRecord>; // sessionKey → whose, and which transcript
|
||||
};
|
||||
|
||||
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();
|
||||
try {
|
||||
if (!existsSync(stateFile)) {
|
||||
@@ -50,6 +67,7 @@ export function loadState(): PersistedState {
|
||||
}
|
||||
const text = readFileSync(stateFile, 'utf-8');
|
||||
currentState = { ...DEFAULT_STATE, ...JSON.parse(text) };
|
||||
currentState.claudeSessions = migrateSessions(currentState.claudeSessions, ownerUserId);
|
||||
return currentState;
|
||||
} catch {
|
||||
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> {
|
||||
ensureDir();
|
||||
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
|
||||
* equality guard collapses it to one write per session, and clearing happens once.
|
||||
*/
|
||||
export function setClaudeSession(sessionKey: string, sessionId: string): void {
|
||||
if (currentState.claudeSessions[sessionKey] === sessionId) return;
|
||||
currentState.claudeSessions[sessionKey] = sessionId;
|
||||
export function setClaudeSession(sessionKey: string, sessionId: string, userId: number): void {
|
||||
const existing = currentState.claudeSessions[sessionKey];
|
||||
if (existing?.claudeSessionId === sessionId && existing.userId === userId) return;
|
||||
currentState.claudeSessions[sessionKey] = { userId, claudeSessionId: sessionId };
|
||||
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
|
||||
* 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);
|
||||
for (let i = keys.length - 1; i >= 0; 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;
|
||||
}
|
||||
|
||||
@@ -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 { homedir } from 'node:os';
|
||||
import type { SidecarCommand, SidecarEvent } from '../protocol';
|
||||
@@ -92,12 +92,40 @@ process.env.HOME = homeDir;
|
||||
// Init per-user state paths
|
||||
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()) {
|
||||
console.error(`[agent] another instance is already running for ${email} (lock file exists with live PID)`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
loadState();
|
||||
loadState(dbUser.id);
|
||||
|
||||
// ── 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');
|
||||
}
|
||||
@@ -226,34 +269,38 @@ async function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
|
||||
}
|
||||
|
||||
case 'claude:kill':
|
||||
claudeManager.killClaudeSession(cmd.sessionKey);
|
||||
claudeManager.killClaudeSession(cmd.sessionKey, cmd.userId);
|
||||
sessionLog.drop(cmd.sessionKey);
|
||||
reply({ type: 'claude:killed', id: cmd.id });
|
||||
break;
|
||||
|
||||
case 'claude:interrupt':
|
||||
await claudeManager.interruptClaudeSession(cmd.sessionKey);
|
||||
await claudeManager.interruptClaudeSession(cmd.sessionKey, cmd.userId);
|
||||
reply({ type: 'claude:interrupted', id: cmd.id });
|
||||
break;
|
||||
|
||||
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;
|
||||
|
||||
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;
|
||||
|
||||
case 'claude:find-session':
|
||||
reply({
|
||||
type: 'claude:session-key',
|
||||
id: cmd.id,
|
||||
sessionKey: findSessionKeyByClaudeSession(cmd.claudeSessionId) ?? null,
|
||||
sessionKey: findSessionKeyByClaudeSession(cmd.claudeSessionId, cmd.userId) ?? null,
|
||||
});
|
||||
break;
|
||||
|
||||
case 'claude:clear-session':
|
||||
claudeManager.clearSession(cmd.sessionKey);
|
||||
claudeManager.clearSession(cmd.sessionKey, cmd.userId);
|
||||
sessionLog.drop(cmd.sessionKey);
|
||||
reply({ type: 'claude:session-cleared', id: cmd.id });
|
||||
break;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user