First slice, on a worktree branch so none of it touches the tree the live server runs from. `sidecar_installs` — server-level, no userId, because a sidecar is one process serving the machine. That is the line that keeps the model coherent for several users: installed is server-level and owner-only, configured is per user in service_connections. A member can use Gitea without being able to install it or point it somewhere else. `installed` and `enabled` are separate because they answer different questions, which is what gives the reversible middle ground: disable stops the process and keeps container, config, schema and data. `completedSteps` makes install resumable rather than merely retryable — the failure mode being designed against is a half-installed service that neither works nor uninstalls. The catalogue is data, not code: no functions, no compile-time coupling, because the same shape has to arrive as JSON from marketplace.officer.dev later. Its test pins it to the real estate — it offers exactly the processes the light profile excludes, names processes that exist, and claims capabilities that exist. That last check earned itself immediately: it caught `vault` (no capability at all — it is EXEMPT because Bitwarden clients carry a Vaultwarden bearer, not a platform JWT) and `notify` (which does have one, where I had written null). Docker templates follow the convention already in use across 47 services in ~/dockers: a directory per service, compose inside, relative bind mounts so data sits beside it, USER_UID/USER_GID as the owner. An existing directory is evidence of an existing install and must be adopted, never overwritten. Records what Phase 0 must not foreclose: a remote marketplace, sidecars moving to their own repositories, and third-party plugins — including the note that catalogue.test.ts pins Phase 0's invariant rather than the design's, since that relationship inverts once sidecars leave this repo. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
220 lines
12 KiB
Markdown
220 lines
12 KiB
Markdown
# Sidecars as installable apps
|
|
|
|
**Status: DESIGN, agreed in conversation 2026-08-10. Nothing implemented.** This supersedes the framing
|
|
of `sidecar-bootstrapping.md`, which stays as the record of how the mechanics work _today_.
|
|
|
|
The goal: a clean machine runs chat, the terminal and the file browser, and **everything else arrives by
|
|
the user asking for it** — from an app store inside Officer. Eventually including sidecars the user did
|
|
not write.
|
|
|
|
---
|
|
|
|
## Why this is mostly not a rewrite
|
|
|
|
Three things are already true, which is why "nothing exactly blocks it":
|
|
|
|
- **Every API route stays mounted regardless of which sidecars run.** The light profile's own comment
|
|
states it: features whose sidecars are absent report themselves unavailable rather than disappearing.
|
|
So the app store never needs to mount or unmount routes.
|
|
- **Officer already spawns nothing.** Sidecars are PM2 peers that dial in and register by capability.
|
|
Installing one is starting a process, not teaching officer about it.
|
|
- **`service_connections` already solves the multi-user case**, including the part nobody would get
|
|
right independently — see below.
|
|
|
|
What is genuinely new: provisioning containers, per-sidecar schema, and persisted install state.
|
|
|
|
---
|
|
|
|
## Light becomes the baseline
|
|
|
|
`ecosystem.light.config.cjs` stops being a variant and becomes what a fresh install runs:
|
|
|
|
```
|
|
officer · officer-anthropic-proxy · officer-agent · officer-opencode · officer-pty · officer-gitea
|
|
```
|
|
|
|
Chat, terminal, file browser. The file-browsing APIs live in the main process, so they cost nothing
|
|
extra.
|
|
|
|
The other fourteen become app-store entries.
|
|
|
|
---
|
|
|
|
## Three install shapes
|
|
|
|
The prompt the user sees depends on which of these the sidecar is. This is the taxonomy the installer
|
|
branches on:
|
|
|
|
| Shape | What install means | Examples |
|
|
| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
|
|
| **Point at an instance you already have** | Ask for URL + credential, write `service_connections`, start the sidecar | gitea, memos, photos (Immich), jellyfin, invoiceshelf, headscale |
|
|
| **Provision one** | Render our compose template, `docker compose up -d`, wait for health, write the connection _we already know_, start the sidecar | vault (Vaultwarden), slskd, transmission, caldav (Radicale), and any of the above where the user has none |
|
|
| **Configuration only** | Ask for credentials, start the sidecar. No service to reach | email (IMAP), notify, music, wallet, vnc |
|
|
|
|
A sidecar can be more than one: Gitea is "existing instance" for someone who runs one and "provision"
|
|
for someone who does not. The prompt is the fork.
|
|
|
|
---
|
|
|
|
## Docker: we install, the user owns
|
|
|
|
**Officer is the installer, never the owner.** Concretely:
|
|
|
|
- A real compose file per service, written into a **user-owned directory**, from our template —
|
|
following the convention the owner already uses for 47 services in `~/dockers/`:
|
|
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 the user can find 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.
|
|
|
|
Consequences, which are the point:
|
|
|
|
- `docker ps`, `docker logs`, `docker compose down` all behave normally.
|
|
- If Officer is removed, the containers keep running and stay manageable.
|
|
- A user who runs his own estate can edit the compose file — it is his file, in his directory.
|
|
- We can always find what we installed without pretending to own it.
|
|
|
|
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.
|
|
|
|
**`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.
|
|
|
|
**An existing directory is evidence, not an obstacle.** `~/dockers/` already holds `gitea`, `memos`,
|
|
`immich`, `jellyfin` and `invoice_shelf`. The installer must never write into a directory that exists;
|
|
finding one is the strongest possible signal that this is the "you already have one" case, and the store
|
|
should offer to ADOPT it — read its ports out of the compose file and write the connection — rather than
|
|
provision a second copy or overwrite a running service's data.
|
|
|
|
`[open]` Podman, for anyone wanting genuinely rootless.
|
|
|
|
---
|
|
|
|
## Install state
|
|
|
|
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 | 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. |
|
|
|
|
The middle one is the in-between; the user chooses disposal at uninstall time rather than us guessing.
|
|
|
|
**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
|
|
works nor uninstalls. Each step records what it did; re-running install resumes rather than restarts.
|
|
|
|
---
|
|
|
|
## Per-sidecar schema
|
|
|
|
Today all 42 tables live in one Drizzle schema and arrive together via `bun db:push`. That changes:
|
|
**each sidecar owns its own schema and applies it on install.**
|
|
|
|
This is right _because third-party plugins are a real goal_. For our own fourteen it would be
|
|
over-engineering — an unused table costs nothing — but a marketplace plugin cannot ship a table into a
|
|
schema it does not own.
|
|
|
|
**The dependency graph makes this tractable.** Measured across the 19 non-core schema files:
|
|
|
|
```
|
|
core: auth.ts, server.ts, chat-events.ts (depend on nothing)
|
|
sidecars: every single one -> auth.ts, and nothing else
|
|
```
|
|
|
|
There is **no sidecar-to-sidecar dependency anywhere**. One file (`user-data.ts`) touches two, and it is
|
|
core. So the contract for a plugin's schema is nearly the smallest it could be:
|
|
|
|
> **You may reference `users.id`. You may not reference anything else.**
|
|
|
|
Which also makes full uninstall well-defined: drop the tables this sidecar declared. Nothing else points
|
|
at them, by construction.
|
|
|
|
`[open]` Where do a plugin's migrations live, and what applies them — the installer, or the sidecar on
|
|
first boot? Versioning and upgrade are unsolved here.
|
|
|
|
---
|
|
|
|
## `service_connections` is part of the contract
|
|
|
|
Decided: it stays **core and shared**, one table, with each plugin identified by its own ID — rather than
|
|
a connections table per service.
|
|
|
|
It already does the hard part. The row is keyed `(userId, service)` and **a NULL `url` means "inherit the
|
|
instance"**: the owner's row carries the URL and _is_ the instance; every other user's row carries only
|
|
their own credential and resolves the base from the owner's row at read time.
|
|
|
|
So "members never see the instance URL" is a property of the schema rather than a filter someone must
|
|
remember on every response — and a member cannot supply a URL, which closes what would otherwise be a
|
|
per-user SSRF hop wearing a settings form. Gitea is the first service of this kind; five sidecars use the
|
|
table today (memos, wallet, transmission, slskd, gitea).
|
|
|
|
A third-party plugin inherits all of that for free, which is the argument for sharing the table: it is
|
|
the part nobody would get right independently.
|
|
|
|
Two things it needs before third parties touch it:
|
|
|
|
1. **Namespaced IDs.** `service` is free text — deliberately, so adding a service is not a schema change.
|
|
With a marketplace, two plugins could both claim `"gitea"` and collide on the unique index. Needs a
|
|
convention (reverse-DNS, or IDs issued by the marketplace).
|
|
2. **Somewhere for plugin-specific config.** The columns are shaped around the services that exist:
|
|
`url`, `username`, `secret`, `path`, `version`. A plugin needing anything else has nowhere to put it,
|
|
and adding a column per plugin defeats the shared table. Likely a `config` JSONB for the remainder —
|
|
with `url` staying first-class, because the inheritance rule above depends on it being a real column.
|
|
|
|
---
|
|
|
|
## The API contract, when we open this up
|
|
|
|
What a plugin author is promised, and bound by. To be written properly; the shape is:
|
|
|
|
- **Register** by name + capabilities over `/api/sidecar/register`; be reachable by capability.
|
|
- **Declare** an ID, an install shape, a compose template (if it provisions), a config prompt, and a
|
|
schema.
|
|
- **May reference** `users.id`, and use `service_connections` under its own ID.
|
|
- **May not** reference another plugin's tables, or write outside its own.
|
|
- **Must** tolerate being disabled, re-enabled, and uninstalled.
|
|
|
|
---
|
|
|
|
## 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.
|
|
2. Plugin migrations: who applies them, how versioned, how upgraded.
|
|
3. `config` JSONB on `service_connections` — or a different escape hatch.
|
|
4. ID namespacing authority.
|
|
5. What the app store does when Docker is absent — hide "provision", or refuse to install?
|
|
6. Does an installed-but-unhealthy sidecar surface in the UI as broken, or as not installed?
|