Files
platform/docs/sidecar-app-store.md
T
pastilhasandClaude Opus 5 be259f813a design: sidecars as installable apps
Agreed in conversation, nothing implemented. Light stops being a variant and becomes the baseline —
chat, terminal, file browser — and the other fourteen sidecars arrive by the user asking for them from
an app store, eventually including sidecars the user did not write.

Mostly not a rewrite, for three reasons already true: every API route stays mounted regardless of which
sidecars run, officer already spawns nothing, and service_connections already solves the multi-user
case. What is new is provisioning, per-sidecar schema, and persisted install state.

Docker: officer is the installer, never the owner. Real compose files in the user's own directory,
started as him, found again by label. `docker compose down` works, and the containers outlive Officer.

Per-sidecar schema is right here specifically because third-party plugins are a real goal, and the
dependency graph makes it tractable: measured across 19 schema files, every sidecar depends on auth.ts
and nothing else, with no sidecar-to-sidecar edges anywhere. So the plugin contract is "you may
reference users.id" — which also makes full uninstall well-defined, since nothing else points at a
plugin's tables.

service_connections stays core and shared rather than per-service, because it already does the part
nobody would get right alone: a NULL url means "inherit the instance", so the owner's row is the
instance and members hold only their own credential, making "members never see the instance URL" a
property of the schema instead of a filter someone has to remember.

Records six open questions rather than settling them, including plugin migrations, ID namespacing for a
marketplace, and where plugin-specific config lives.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 12:10:09 +00:00

185 lines
9.5 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 `docker-compose.yml` per service, written into a **user-owned directory**
(`~/officer-services/<service>/`), from our template.
- 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.
`[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?
---
## 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.
---
## 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?