diff --git a/docs/sidecar-app-store.md b/docs/sidecar-app-store.md new file mode 100644 index 00000000..f6c088f9 --- /dev/null +++ b/docs/sidecar-app-store.md @@ -0,0 +1,184 @@ +# 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//`), from our template. +- Started with `docker compose up -d` **as the owner**, not as officer's own identity. +- Found again by **label** (`officer.sidecar=`), 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?