MusicBrowser lists folders with GET /file-browser/ls rather than through the music sidecar. /file-browser belongs to `files`, which is `confined` — so a member granted `music` and not `files` gets a working player, working favourites and an empty library, and `files` is not a grant that can simply be handed over, since authorize.ts drops a confined grant for an account with no Linux user. First cross-plugin PERMISSION dependency in the system, and a different animal from offscale's. That one is ConsoleView → TerminalView: code, resolved at build time, worst case a plugin that will not compile. This resolves at request time, per account, and fails as a screen that renders perfectly and shows nothing. Three shapes written down, none chosen. Moving the listing into the sidecar is probably right — it is the same rule offscale follows, that the sidecar absorbs everything — but it is tomorrow's call. Recorded now rather than trusted to memory: it was found by reading, after the extraction was already verified and pushed, so nothing was going to surface it again on its own.
16 KiB
Music — the second plugin
Status: extracted 2026-08-15. Written after the fact rather than during, because unlike offscale this
one had no design questions left open — the runbook (plugins/EXTRACTING-A-PLUGIN.md) had already decided
everything except one call. This records what moved, what did not, and the two bugs the extraction found.
Read plugins/offscale/PLUGIN.md first. It is the design document for the plugin system; this is a
worked second case, and it is interesting mainly for being the messy one.
What music is
The /music screen, the library index, and the phone and tablet apps that stream from it. The contract
those apps speak is MUSIC_API.md, next to this file — it is the reason the sidecar's HTTP shape is not
free to change.
manifest.ts identity, one permission
api/router.ts re-exports the platform's proxy — see below
sidecar/index.ts the whole /api/music contract (503 lines)
sidecar/indexer.ts the library walker → cache tree + manifest (1079 lines)
sidecar/stream-audio.ts 206 / Content-Range / 416, and X-Audio-Duration
sidecar/nightly-reindex.ts 3am full rebuild, staged and swapped
db/ music_favorites, _playlists, _playlist_items, _now_playing
web/ two panels and a layout; the shell renders the Workspace
scripts/ the reindex CLI, which talks to the sidecar port directly
The three things that stayed, and why
Offscale left nothing behind. Music leaves three, and calling them seams rather than loose ends only means each one is written down with what would close it.
1. cliamp — out of scope by decision
cliamp and cliamp-audio are a second playback path: the cliamp TUI run on the server, with its
terminal and its PulseAudio null sink piped to the browser. The owner's call was that it is the least
important part of music and not worth blocking the extraction on.
It was already inert before any of this — the two sockets are declared in server.tsx's route table and
upgrade into handlers entries that are commented out. So:
src/servers/sidecar/music/still holdscliamp-ws.ts,pulse-audio.ts,asoundrcandcliamp-ws.test.ts. Untouched.- This plugin's sidecar still serves those sockets, so it imports both modules from
@@/sidecar/music/. A plugin importing platform code is ordinary; the reverse would not be. src/servers/api/cliamp/relay.tsstays, and it is what keeps the next item alive.
2. src/servers/api/music/router.ts — kept alive by the relay
relay.ts imports getMusicServerWsUrl from it. So the platform's proxy could not move, and this
plugin's api/router.ts re-exports it rather than building a second one.
That is not laziness. createSidecarProxy learns its port from a one-shot music:server event and
subscribes at import. Two proxies would mean two subscribers, both working today, and a 503 on the
first reconnect where only one of them happened to be listening — the same class of failure as the
install-order bug offscale found, and just as invisible from reading.
3. The player — the one open judgement call, and it is decided
officerdev/src/MusicPlayer/ stays in the platform. The runbook left this open with either answer
acceptable. What decided it was not the overlay but the state:
useMusicPlayerandPlayerTrackare imported fromofficerdevbysrc/workspaces/widgets/MusicPlayer/, the dashboard widget — which is also out of scope and stays. The platform cannot import from a plugin. So the player state stays here whatever is decided about the UI around it, and a second copy would mean two audio engines fighting over one pair of speakers.
Given the state had to stay, splitting the engine and the bar away from the thing they drive would have left the same seam in a worse place. And moving them needed a shell slot that renders a plugin-provided component on every route — which is exactly the escape hatch this system deleted on purpose. "There is no way to export a component" is what makes "every plugin route is a Workspace" a property of the shape rather than a rule someone has to remember, and reopening it for one plugin is a bad trade.
The seam is inert without the plugin: MusicPlayerHost gates on can('music'), and music is now the
plugin's permission — registered at install, gone at uninstall.
What stayed with it, and why each: gapless-engine (the engine the state drives), player-time (the
module-level bridge the lyrics pane meets it through), useLyricsOpen and MusicHeart +
useMusicFavorites (the bar renders a heart), and shared.ts — the library vocabulary, which the host
needs a third of and the plugin needs all of. One definition on the host side beats a copy either side
of the boundary drifting apart; plugins/music/web/shared.ts re-exports it from the package's declared
officerdev/MusicPlayer/shared subpath.
What would close it: the widget learning to come from a plugin. Not the overlay slot — that one should stay shut.
Two bugs, neither visible from reading
The app-store catalogue still listed music, and that would have blanked the screen.
capabilityAvailability() derives from sidecar_installs, and a plugin never gets a row there — its
install state is plugin_installs. So music would have been permanently unavailable, which puts
/music into deniedRoutes: dock tile withheld, screen blank, on a server where the plugin was
installed, enabled and healthy.
This is the headscale bug, exactly — and it is documented six lines above where the music entry sat, in the same file. Found by reading that note rather than by hitting it again, which is the only reason it cost minutes instead of an evening. Entry removed.
[test] root = "./src", so moving lyrics.test.ts into plugins/ stopped running it silently. The
count fell by nine and the suite still read green-ish. A test that quietly stops running is worse than
one that fails, and every future extraction would have taken its tests out of the suite the same way.
Root is now the repo. Positional filters cannot fix this — bun test plugins matches paths under root,
so it finds src/servers/plugins/ and not plugins/.
Permissions
One permission, music, and the key is deliberately unchanged from the registry entry it replaces — so
every existing role_capabilities grant keeps meaning what it meant, and can('music') keeps resolving
for the overlay. Renaming it would have been a silent data change.
The old entry carried personal: ['/favorites', '/now-playing', '/playlists', '/queue']. A manifest has
no personal field and should not grow one: that is the per-user visibility model, which is the plugin's
own job and explicitly not this extraction's work. They ride across on readOnlyWrites instead, because
isRequestAllowedAtLevel concatenates the two lists — one mechanism under two names. A read grant
therefore permits exactly the four paths it permitted yesterday, and no field was added.
/queue is in that list because it was. No such route exists, in the sidecar or anywhere else.
[open] What a member's grant means is unfinished, and music is where the richer model was always
going to be designed (plugins/offscale/PLUGIN.md says so). It is genuinely non-uniform here in a way
offscale's is not: favourites, playlists and now-playing are already per-caller — the sidecar scopes
every one by the X-Officer-User header the proxy injects — while the library is one shared index for
the household. So "whose row is this" already has a real answer on one side and not the other. That is a
change inside db/queries.ts, not a flag on the manifest.
Host dependencies — the field music created
ffmpeg and ffprobe. Offscale needed nothing, so until music there was no reason to build this and no
way to say it; the first draft of this document said "there is no field for a host binary" and left it at
that. That was the wrong answer, because of HOW music fails without them.
It does not fail. ffprobe missing means the indexer catches the spawn error and returns a track carrying
its filename and nothing else — no title, artist, album, duration or embedded lyrics — then walks the
whole library, writes a complete cache tree and reports success. Five swallowed catches in
indexer.ts and stream-audio.ts, no log, no counter. The only tell is coversSaved: 0 in a report
nobody reads. A refusal wearing the costume of a normal result.
So osDependencies is a manifest field now (servers/plugins/manifest.ts, servers/plugins/os-deps.ts):
osDependencies: [
{ binary: 'ffprobe', reason: '…', packages: { apt: 'ffmpeg', pacman: 'ffmpeg', dnf: 'ffmpeg', brew: 'ffmpeg' } },
{ binary: 'ffmpeg', reason: '…', packages: { … } },
]
Both are declared even though one package provides both, because the platform probes BINARIES and these
two fail differently — and the owner should be told which one they are missing. The installer dedupes to
a single ffmpeg before anything reaches a command line.
The shape is scripts/setup-old/setup.sh's, not invented: probe the binary, map to a package name per
manager. Probing the binary is what makes "built-in on this OS" free — if it is on PATH the package map
is never consulted. Per-manager names rather than canonical-with-overrides because packages.sh already
recorded why that indirection was rejected.
Verified end to end on 2026-08-15. Both binaries were absent on this machine all evening. The plugins
page showed ffprobe missing — ffmpeg and ffmpeg missing — ffmpeg with the exact root command it would
run; installing streamed dependencies: installing ffmpeg with apt → dependencies: ffprobe, ffmpeg now on PATH, and X-Audio-Duration: 7.026939 appeared on a stream response for the first time. The refusal
path was exercised separately against a temporary probe dependency: HTTP 400, steps: [], and the reason
named — nothing had happened, so there was nothing to undo.
~/Music still does not exist, so there is no library to index.
cliamp, parec, pulseaudio and pactl stayed behind with cliamp. The sidecar logs
pulseaudio not installed, skipping audio setup and carries on, which is the right shape.
Verified on the live server, 2026-08-15
The runbook's table, run against platform.officer.dev rather than reasoned about.
| Step | Result |
|---|---|
| install | streamed 5 steps; schema applied in 2082ms; officer-music online; /example, /music, /offscale mounted |
| the API | /api/music/manifest 200, /api/music/favorites returns per-user JSON |
| range requests | full 200 + Accept-Ranges; bytes=100-199 → 206, correct Content-Range, exactly 100 bytes; unsatisfiable → 416; ../../etc/passwd → 400 |
| the screen | route generated in Plugins.gen.tsx, panels in the built bundle, PluginScreen wraps WorkspaceView. Structural — not eyeballed in a browser |
| dock | tile present in /api/user/capabilities; /music in routes, not in deniedRoutes |
| permissions page | music listed among the grantable |
| disable | route 404s, sidecar stopped, rows survive |
| enable | 200 again, sidecar online, favourites still there |
| uninstall | route 404s, absent from pm2, ecosystem entry removed, rows survive |
bun db:push while uninstalled |
No changes detected, rows survive |
| install again | byte-identical steps, and a restore — the seeded favourite and playlist came back |
pm2 restart officer |
boots clean, all three plugins mount, music answers 200 |
Seeded rows and the audio fixture were removed afterwards; ~/Music was deleted again, since it did not
exist before.
Music is left INSTALLED and enabled. It had been switched off since 2026-08-13, so this restores it.
Still open
The library browser reads the filesystem, not this plugin — and that is a PERMISSION dependency
Found 2026-08-15, after the extraction landed, by reading the code rather than by anything failing.
MusicBrowser.tsx lists folders with GET /file-browser/ls, not through the music sidecar
(MusicBrowser.tsx:63,81). /file-browser belongs to the files capability, and files is
confined — so:
- a member granted
musicbut notfilesgets a working player, working favourites, and an empty library, because every listing 403s; - and
filesis not a grant that can simply be handed over.authorize.tsdrops a confined grant for an account with noosUser, so it means nothing without a per-user Linux account.
This is the first cross-plugin permission dependency in the system, and it is a different animal from
the one offscale has. Offscale's ConsoleView → TerminalView is a CODE dependency: it resolves at build
time, and the worst case is a plugin that will not compile. This one resolves at request time, per
account, and its failure mode is a screen that renders perfectly and shows nothing.
Three possible shapes, none chosen:
- The sidecar lists. Music already walks the library for its index —
GET /music/lswould put the listing behind themusicpermission where it belongs, and the plugin stops needingfilesat all. Most self-contained, and the most work. - The manifest declares a permission dependency, and the platform refuses the grant or warns. Honest, but it makes one plugin's grant conditional on another capability, which is new machinery.
- Leave it and document it — a member needs
filestoo. Cheapest, and it quietly ties a music grant to a Linux account, which is a much bigger commitment than the owner is agreeing to on that page.
(1) is probably right, and it is the same shape as offscale's rule that the sidecar absorbs everything. Not tonight's call.
hasPersonalWritesreadsc.personalonly, so the permissions API reportsfalsefor a plugin that declares the same thing throughreadOnlyWrites. Nothing renders the field, so it is dead on the wire — noted rather than fixed.- Two dock sources. The app store keeps its own catalogue while the plugin system builds tiles from manifests, and the self endpoint concatenates both. One when the store is rebuilt on the plugin system.
src/servers/sidecar/protocol.tsstill declaresmusic:serverper sidecar. Generalising the union to`${string}:server`is the better fix and is pending for the whole protocol.- The cliamp sockets are claimed by no capability, and are served. Now pinned by a test in
registry.test.tsrather than left to be rediscovered — closing it is the totality work.