Files
platform/docs/sidecar-app-store.md
T
pastilhasandClaude Opus 5 fc48a572d2 app store: the catalogue, the install-state table, and what phase 0 must not foreclose
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>
2026-08-10 13:44:30 +00:00

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?