117 Commits
Author SHA1 Message Date
pastilhas 8bfcd40bd2 music's library browser depends on another plugin's permission, not just its own
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.
2026-08-15 03:09:50 +00:00
pastilhas e930586878 plugins declare the host binaries they need, and the installer checks
Offscale was self-sufficient. Music is not — it shells out to ffmpeg and
ffprobe — and the way it fails without them is the reason this is a check
rather than a line in a README.

It does not fail. Missing ffprobe 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. It then walks the whole library, writes a
complete cache tree and reports success. Five swallowed catches, no log, no
counter, and the only tell is coversSaved: 0 in a report nobody reads.

So `osDependencies` is a manifest field: the binary to probe on PATH, why it
is needed, and a package name per package manager. The shape is taken from
scripts/setup-old/setup.sh rather than invented — probe the binary, case on
$PM — and the names are per-manager rather than canonical-with-overrides
because lib/packages.sh already recorded why that indirection was rejected.
Probing the binary is what makes "built-in on this OS" free: on PATH means the
package map is never consulted.

Four decisions worth naming.

Missing and uninstallable REFUSES the install, first, before a table is
created or a row written — so there is nothing to undo, and the alternative is
a plugin that installs, answers 200 and quietly produces nothing.

The status is on GET /api/plugins and rendered before the button, because the
owner is deciding whether to let the server run a package manager as root and
that needs answering first. Installing by hand and watching it flip to present
is the escape hatch on a machine without passwordless sudo.

Package names get a deliberately narrow regex and reach Bun.spawn as an argv
ARRAY, never a shell. Both halves are load-bearing: the regex means a
metacharacter cannot get there, argv means it would be an argument rather than
syntax if it did. Narrower than package managers actually accept — no `:`, no
`+` version pins — because a plugin needing one wants a conversation.

Success is OBSERVED, not inferred: after installing, the binaries are re-probed.
A package manager exiting 0 having installed something that does not provide
the binary is exactly the failure this exists to catch.

installCommand mirrors lib/packages.sh's pkg_install_now exactly, including
apt's non-interactive environment, so there is one definition of "install a
package" rather than two that drift. sudo always gets -n: under PM2 a password
prompt is not a slow path, it is a hang. brew never escalates.

Verified live. ffmpeg and ffprobe were absent on this machine all evening; the
page showed both missing with the exact root command, the install streamed
`dependencies: installing ffmpeg with apt` then `ffprobe, ffmpeg now on PATH`,
and X-Audio-Duration appeared on a stream response for the first time. The
refusal path was exercised against a temporary probe dependency: HTTP 400,
steps: [], reason named.

THIS CHANGED THE MACHINE: ffmpeg 6.1.1-3ubuntu5 is now installed via apt.

Found on the way: a manifest is read once per process. Discovery does
`await import()` and the module cache holds it, so editing a manifest changes
nothing until pm2 restart officer — including `outdated`. Cost ten minutes and
is now in the runbook.

bunx tsgo clean. 797 tests, 787 pass, 7 fail — the same seven, +25 new.
2026-08-15 02:33:18 +00:00
pastilhas 05eb947bd1 music is verified on the live server, and the runbook learns from it
Ran the whole table against platform.officer.dev rather than reasoning about
it. install / API / range requests / dock / permissions / disable / enable /
uninstall / db:push-while-uninstalled / reinstall / pm2 restart officer — all
pass, recorded in plugins/music/PLUGIN.md with the actual numbers.

Two results worth naming. The reinstall was a RESTORE: a favourite and a
playlist seeded before uninstall came back untouched, which is the whole point
of the barrel following directories rather than the install table. And
`bun db:push` while uninstalled said `No changes detected` with the rows still
there — the property that makes running push by hand safe at any moment.

Range survives the proxy hop: 206 with a correct Content-Range and exactly the
bytes asked for, 416 unsatisfiable, 400 on a path escaping the Music root.

Two findings from the machine rather than the code. ffmpeg and ffprobe are not
installed here, so X-Audio-Duration never appears and indexing would produce no
tags or covers — the manifest already says a host binary cannot be declared, and
now says it was checked. And ~/Music did not exist at all, so the library is
empty; the sidecar handles both absences and logs them rather than failing.

Music is left INSTALLED and enabled. It had been switched off since 2026-08-13,
so this restores it.

The runbook gains what generalises: map what the PLATFORM still needs from your
feature before planning the split, because two imports pointing the wrong way
dictated music's boundary rather than any judgement about what music is.
Offscale had none, which made the job look cleaner than it is. Also two new
traps — delete the app-store catalogue entry or the screen goes blank, and
compare test FILE COUNTS across a run, since that is the only thing that shows
a test which stopped being discovered.

The global-overlay question is recorded as answered: no. A shell slot for a
plugin-provided component reopens "there is no way to export a component", and
that rule is what the frontend contract rests on.

Seeded rows and the audio fixture removed; ~/Music deleted again.
bunx tsgo clean. 772 tests, 762 pass, 7 fail — all pre-existing.
2026-08-15 01:56:16 +00:00
pastilhas de3340398c music becomes a plugin, and the player stays behind
The whole of music moves to plugins/music/: the sidecar (index, indexer,
stream-audio, nightly-reindex), the four Postgres tables and their queries,
the /music workspace panels, MUSIC_API.md and the reindex CLI. The platform
keeps no music routes, no music capability entry, no music screen and no
music schema.

Three things stayed, each on purpose.

cliamp and the widget were out of scope by the owner's decision. The plugin's
sidecar still serves the two cliamp sockets, so it imports cliamp-ws.ts and
pulse-audio.ts from @@/sidecar/music/ — the files stay where they were.

The player did not move, and that was the open judgement call. Deciding it
took one fact: the dashboard widget imports useMusicPlayer and PlayerTrack
from officerdev, and the platform cannot import from a plugin. So the player
STATE stays whatever is decided about the UI around it, and two copies would
mean two audio engines. Given that, the engine and the bar stayed with the
state rather than being split from the thing they drive. Moving them would
also have needed a shell slot rendering a plugin-provided component on every
route — the one escape hatch this system deleted on purpose. MusicPlayerHost
gates on can('music'), which is now the plugin's permission, so the seam
switches itself off with the plugin.

api/music/router.ts stays too: api/cliamp/relay.ts imports getMusicServerWsUrl
from it. The plugin's api/router.ts re-exports that proxy rather than building
a second one — two subscribers to the one-shot music:server port announcement
would work today and 503 on the first reconnect where only one was listening.

Two bugs found on the way, neither visible from reading.

The app-store catalogue still listed music. Availability is derived from
sidecar_installs and a PLUGIN never gets a row there, so `music` would have
been permanently unavailable — which puts /music into deniedRoutes and blanks
the screen on a server where the plugin was installed and healthy. Exactly
the headscale bug documented six lines above it in the same file, and it would
have fired on the first install. Entry removed.

[test] root was "./src", so moving lyrics.test.ts into plugins/ stopped it
running and said nothing — the count fell by nine and the suite still read
green. Root is now the repo. Positional filters cannot fix this: `bun test
plugins` matches under root and finds src/servers/plugins/ instead.

registry.test.ts tested the `personal` mechanism THROUGH the music capability.
Re-anchored on a fixture rather than on another entry, because borrowing a
feature only moves the problem to the next extraction — and three of those
four tests had been passing for the wrong reason since music's api was
commented out on 2026-08-13, when everything started resolving to "refused
because nothing is claimed". The cliamp sockets being claimed by nothing is
now pinned by a test instead of being rediscovered.

music's `personal` paths ride across on readOnlyWrites, the one field a
manifest has. isRequestAllowedAtLevel concatenates the two lists, so a read
grant permits exactly the four paths it permitted yesterday, and no field was
added to the manifest to design a per-user model that is not this work.

bunx tsgo clean. 772 tests, 762 pass, 7 fail — all seven pre-existing and
unrelated (cliamp, pty, and five capability tests that other switched-off
plugins break). Baseline was 757/10; the three that went green are the ones
re-anchored above.

Not yet verified on the live server — that is next.
2026-08-15 01:46:43 +00:00
pastilhasandClaude Opus 5 18c4ebd0b4 the per-user model is not tonight's work either
personal, the four user-scoped tables, and what a member's grant means all stay
exactly as they are. it gets built inside the plugin later, which is the entire
reason the platform's answer is a uniform read/write and nothing more.

leaving it alone is safe rather than lazy: the web frontend does not use those
routes at all — favourites, playlists and now-playing are used only by the phone
and tablet apps — so nothing visible in a browser can regress by carrying them
across verbatim. and /queue, which is in that list, has no route anywhere. dead
or aspirational; carried as-is, not investigated.

the one unavoidable consequence stays named: registry.test.ts tests the personal
mechanism through the music entry, so removing it breaks those tests. re-anchor
on another entry that has personal. a test fix, not a redesign.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 01:24:09 +00:00
pastilhasandClaude Opus 5 7d65732f77 the widget is out of scope too, leaving one open call
widgets/MusicPlayer stays in its third workspace package, registered where it
is. plugins cannot contribute widgets and are not going to learn how tonight.

with cliamp and the widget both cut, exactly one judgement is left in the music
extraction: whether the global MusicPlayerHost overlay gets a contribution slot
in the shell or stays in DashboardLayout gated as it already is. either is fine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 01:21:54 +00:00
pastilhasandClaude Opus 5 b5db3c47e1 cliamp is out of scope, which unblocks the music extraction
the two websocket providers exist for one thing: running the cliamp TUI on the
server and piping its terminal and audio to the browser, via a pulseaudio null
sink tapped by parec. a second, separate playback path — and the least important
part of music. wanted eventually, not tonight.

that removes the hardest of the three homeless parts entirely. no plugin can own
a socket yet, and now none needs to: both sockets are already inert, and
cliamp-ws.ts, pulse-audio.ts, asoundrc, the relay and the two providers all stay
exactly where they are. if the relay's import is the only thing keeping
api/music/router.ts alive, that stays too — a small documented seam.

what music actually is: the /music screen, the library, and the phone and tablet
apps that stream from it. that is what has to work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 01:19:18 +00:00
pastilhasandClaude Opus 5 8545b427dd the runbook tells the next agent to decide, not to ask
it previously said 'decide before moving a file, and tell me the decision',
which turns an overnight run into a blocked one. every open call here has two
defensible answers, a corrected decision is cheap, and a stalled extraction is
not.

adds a default for each of the three homeless parts — close the platform gap
when there is runway, because every later plugin needs it too, but a working
music with cliamp left in the platform beats a perfect design that did not land.
the bar at the end is unchanged: install, enable, disable, uninstall cleanly. a
piece left in the platform is a documented seam; a piece left dangling is a bug.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 01:13:49 +00:00
pastilhasandClaude Opus 5 965ced52a6 a runbook for the next extraction, and a warning about music
plugins/EXTRACTING-A-PLUGIN.md: the rules that are not preferences, the order
that worked, the verification cycle the owner actually ran, and the traps that
each cost real time.

the music section is the important half. music is NOT a bigger offscale — three
of its parts have nowhere to go: two websocket providers (and no plugin can own
a socket), a global UI overlay rendered by DashboardLayout on every route, and a
dashboard widget in a third workspace package. its relay is platform code that
imports the music router, so deleting that router breaks the platform. it also
needs six external binaries a manifest cannot declare, ships a non-TS asset, and
is the worked example the capability tests are written through.

recorded before starting rather than discovered halfway, because a half-extracted
music is worse than an unextracted one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 01:11:14 +00:00
pastilhasandClaude Opus 5 4a9f23c759 the design doc moves into the plugin it produced
docs/offscale-plugin.md becomes plugins/offscale/PLUGIN.md. most of it is about
the plugin system generally rather than about offscale, which is exactly why it
belongs with the worked example — the reasoning is most useful beside the code
it produced, and the platform's docs should not carry the history of something
it no longer knows exists.

every reference updated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 01:07:48 +00:00
pastilhasandClaude Opus 5 b4dab16d2a the schema barrel stops describing a world that ended
three things wrong in one paragraph: it named ecosystem.light.config.cjs, which
no longer exists; it called officer-headscale core, which it stopped being on
2026-08-14; and it said installing a plugin 'will have to uncomment its line and
push, and building that is still ahead of us', which was built yesterday.

now it says what is true — a plugin owns its schema at plugins/<name>/db and
install generates the barrel and pushes — and keeps the commented lines for the
sidecars still waiting their turn, which is what they are actually for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 00:52:37 +00:00
pastilhasandClaude Opus 5 2c89281bfc drop a comment describing an export that left with offscale
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 00:49:32 +00:00
pastilhasandClaude Opus 5 585c046a64 the plugin system says permissions, not the other word
i named the field permissions and then narrated in the overloaded one anyway,
which is worse than either — the whole reason for the rename was that the word
already means three things here.

renamed what was mine: setPluginCapabilities -> setPluginPermissions,
pluginCapabilities -> pluginPermissions, and the prose throughout.

what remains is the platform's own vocabulary, not the plugin system's: the
 type it imports, and the  field on a dock manifest,
which is the shape the shell already renders. renaming those is a separate
change to a separate system and is the owner's to make.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 00:47:18 +00:00
pastilhasandClaude Opus 5 8cc51cfb40 every plugin permission is grantable, and there is no field to say otherwise
offscale was missing from the permissions page because it declared ownerOnly,
which mapped to kind admin, and admin capabilities are never offered for
granting. correct by the old rule and wrong by the standard: every plugin
follows the same platform-level permission model, appearing on the same page
with the same read/write/none per role.

so the field is gone rather than flipped. a plugin has no way to say owner-only,
which makes the standard structural instead of remembered — the same move as the
workspace rule. core, execution, confined and admin stay the platform's to
assign and a plugin cannot name any of them, so the escalation question is
removed rather than answered.

this is the second draft of this decision to be deleted: first a full
CapabilityKind with three of five values forbidden, then an ownerOnly boolean,
now nothing. the doc records all three so the reasoning is visible rather than
just the conclusion.

finer visibility stays the plugin's job. offscale is the worked example of the
gap that leaves and its manifest says so: it is grantable now, and its queries
still scope by the caller, so a granted member would see their own empty server
list rather than the owner's. closing that is a change inside the plugin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 00:42:35 +00:00
pastilhasandClaude Opus 5 8b6cb34ae0 plugins contribute dock tiles, and the doc says what is actually built
offscale had no dock tile: that field comes from the app store's catalogue, so a
plugin installed through the plugin system was reachable only by typing its url
or following the link on /plugins.

built at runtime rather than baked into the generated bundle, deliberately —
WHO sees a tile is a permission question, and a grant takes effect on the next
request rather than the next build. presentation comes from the manifest and the
route from mountPrefix, so there is one source for both, and  is the
plugin's first permission so the endpoint can filter a tile out for an account
that cannot reach the screen.

two sources for tiles today, because the app store still has its own catalogue.
one when it is rebuilt on this.

the doc now records the system as complete rather than half-stubbed, including
the three bugs the extraction found — the sidecar-before-mount ordering, the
missing tailwind, and the build that could delete its own shell — and what is
genuinely still open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 00:31:54 +00:00
pastilhasandClaude Opus 5 8587ae20b7 a plugin creates its own tables, and uninstalling never drops them
the last unwired step. install now generates a drizzle barrel of plugin schemas
and runs db:push, so a plugin with db/schema.ts brings its tables with it.

the barrel follows the plugin DIRECTORIES on disk, not the install table, and
that difference is the entire safety property. push drops what it cannot see, so
a barrel tracking installs would delete a plugin's tables the moment it was
uninstalled — turning "stop running this" into "delete my data", which is the
one thing the install model refuses to do. following the directory means:

  directory present, not installed   in the barrel, tables exist unused
  installed                          in the barrel, tables in use
  uninstalled                        STILL in the barrel, every row survives
  directory deleted                  out of the barrel, a push may drop them

so reinstall is a restore, and losing data requires deliberately deleting a
plugin's source.

proved end to end rather than argued. with offscale uninstalled and its entry
removed from the barrel, db:push DROPPED headscale_servers. installing it
recreated the table in 1882ms — columns, both unique indexes including the
partial one that enforces a single active server, and the fk. a canary row then
survived an uninstall AND a subsequent manual db:push, which reported "No
changes detected".

it shells out to the same `bun db:push` a human runs rather than driving
drizzle-kit in-process: one definition of applying the schema instead of two
that can disagree, and an owner can reproduce exactly what an install did.
--force because the barrel only ever gains entries unless source is deleted, and
a prompt with no terminal would hang an install rather than fail it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 00:21:11 +00:00
pastilhasandClaude Opus 5 e13128846b offscale is a plugin
headscale leaves the platform. 45 files move to plugins/offscale/ and the
platform stops knowing it exists.

  api/router.ts   the thin auth-gated proxy, now at /api/offscale
  sidecar/        18 files, the whole headscale contract and its admin keys
  db/             schema + queries, offscale_servers
  web/            26 files as panels and a layout — no screen, per the rule

removed from the platform: the hono mount, the `headscale` capability, the
App.tsx route pair, the screen and its barrel, the AppRegistry spread, the
officerdev re-exports, the dock tile, the page-title rule, and both database
barrels. tsgo is clean and nothing references it.

the imports tell the story of what the plugin↔host API actually is. the sidecar
takes @@/sidecar/protocol, @@/sidecar/connect, @@/data-path and
@@/officer-url.mjs; the queries take officerdb/db and officerdb/crypto; the
schema takes officerdb/auth/schema for the one reference a plugin may make; the
web half takes useClient, copyToClipboard, WorkspaceView and TerminalView from
the officerdev barrel. all of it resolves because a plugin lives inside the repo
— no publishing, no version negotiation.

AND IT FOUND A REAL BUG IN THE INSTALLER. createSidecarProxy learns its port
from a one-shot `<name>:server` event and subscribes when the plugin's router is
first imported — at mount. install started the sidecar BEFORE mounting, so the
announcement fired into a void: process online, routes mounted, every request
answering `503 sidecar not available` until something forced a reconnect. it
would have hit every plugin with an http sidecar. `example` never caught it
because it has no listener to announce.

install and enable now mount before starting; disable still unmounts before
stopping. neither direction leaves a mounted route in front of a sidecar that
cannot be reached.

verified live: /api/offscale/_officer/servers answers {"servers":[]}, /offscale
and /offscale/nodes serve, the old /api/headscale is 404, the offscale
capability is registered from the manifest, and officer-offscale is online.

757 pass, same 10 pre-existing failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 00:15:38 +00:00
pastilhasandClaude Opus 5 0e24aa3d52 a way into the plugin from its detail panel
installing something and then having to guess its url is a small thing that
makes the whole flow feel unfinished. the detail panel now links to the plugin's
screen.

shown only while installed AND enabled, and only when the plugin has a frontend
at all. a link to an unmounted route lands on the home page, because the shell
redirects an unknown path — which reads as a broken link rather than a plugin
that is switched off. a backend-only plugin has no screen to open and gets no
link rather than a dead one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 00:02:09 +00:00
pastilhasandClaude Opus 5 543e88a9a6 every plugin route renders a workspace, and it is not a rule you can forget
an exclusionary rule, made structural. a plugin does not render a screen: it
contributes panels and says how they are arranged, and the shell renders
WorkspaceView around them.

    web/panels.ts   appRegistryMetas — at least one panel
    web/layout.ts   defaultLayout — how they are arranged

both required the moment web/ exists, and missing either is refused at discovery
by name and with the reason. tested:

    probeplug: has a web/ directory but is missing web/layout.ts.
    Every plugin route renders a Workspace: contribute panels and a layout,
    not a screen.

there is deliberately no way to export a component. one that could would be free
to render a bare div, a full-page form, or its own navigation, and the platform
would become a shell hosting strangers' layouts rather than one application.
non-compliance is not so much refused as unrepresentable — there is nowhere to
put a screen.

the shell registers <prefix> and <prefix>/:section, exactly as the core screens
do, so a plugin's sections stay addressable and cmd-clickable, and panels read
useParams independently rather than passing state between themselves.
appTypes.allowed is pinned to that plugin's own keys, so a persisted layout
naming something else falls back instead of rendering another plugin's panel
inside this screen.

the example plugin is rebuilt to model it — two panels, a layout, one of them
calling its own /api/example/ping through useClient — because the reference
implementation is what everyone copies.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 23:47:48 +00:00
pastilhasandClaude Opus 5 2e3c935da6 the built SPA had no tailwind, and the build could destroy itself
three fixes to last night's build switch, all found by using it.

THE CSS. bunfig.toml declares the tailwind plugin under [serve.static], which
applies to bun's static SERVING — the html-import path the app used until
yesterday — and not to a programmatic Bun.build(). so the first build emitted
the xterm css and no tailwind at all: layout intact, every utility class
missing. a plugin list is not inherited from bunfig; it has to be passed. css
goes 110KB to 278KB, 1127 --tw- variables, .flex present, --color-duck present.

THE BUILD DIRECTORY. clearing it before building was meant to stop 20MB of
content-hashed chunks accumulating per install, and instead meant a FAILED build
left nothing — the exact opposite of the promise in the comment directly above
it. it was also a race: two builds overlapping had one process's rm delete the
other's shell, leaving js and css with no html and a 503 that read as a build
failure when the build had succeeded.

now it builds into build.next/ and swaps only a complete, successful build into
place, and refuses to swap one that produced no shell at all — a build can
report success and emit no html, and serving that is worse than serving the
previous one.

AND IT SAYS WHICH PATH IS SERVING. "is it serving the build I just made, or the
one bundled at import?" was answerable only by hiding the shell and watching for
a 503, which is how it got answered once. the distinction matters precisely
where it is hardest to see: the html import is fixed when the module graph
loads, so an install would rebuild build/ and serve something else entirely.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 23:26:28 +00:00
pastilhasandClaude Opus 5 7b4137ccca a plugin's frontend, generated and rebuilt without a restart
the last piece. installing a plugin now brings its UI with it.

a bundler cannot follow import(runtimeString), so which plugins have a frontend
cannot be answered from the database at render time — it has to be written into
source first. Plugins.gen.tsx is that file: concrete imports, generated from
what is installed, gitignored because it describes THIS machine.

App.tsx keeps its core routes and gains one map. the wildcard hands the whole
subtree to the plugin's own router, which react-router nests natively.

serving moved to build/ in production. the html import is bundled once when the
module graph loads and can never change after, which is precisely why a plugin's
frontend needed a restart; Bun.build measures ~900ms for a 25MB bundle, so an
install can just rebuild. development keeps the html import, because that is
what gives HMR and bun --watch restarts on every source change anyway.

verified end to end against a running server, no restart at any point: install
regenerated the module, rebuilt the bundle (chunk hash changed), and the
plugin's own markup was in it; /example and /example/deeper both served; disable
took it back out of both the module and the bundle and 404'd the api; enable put
it back.

three things worth recording because they were found rather than reasoned:

the shell output is named after the ENTRYPOINT — index.gen.html, not index.html
— and naming: { entry: '[name].[ext]' } does not change it because [name] is
'index.gen'. found as a 503 on the first boot after the switch.

App.tsx already destructured a `plugins`, from useServerSettings — the DEAD
plugin system that scans a directory which does not exist and always returns [].
it silently shadowed the import. the new one is `installedPlugins` and says why.

seedAppRegistry takes plugin panels as an argument rather than importing them:
officerdev is a dependency of the shell, so importing upward would invert that.

756 pass, same 10 pre-existing failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 23:07:18 +00:00
pastilhasandClaude Opus 5 a00116b2c0 a plugin's permissions become real capabilities, and survive a restart
two gaps between "a plugin can mount routes" and "a plugin is part of the
platform". both closed.

FIRST: nothing registered a plugin's declared permissions, so the capability
gate could not resolve a plugin path at all. it resolved to null, and null is
denied — the owner never noticed because isSuperAdmin short-circuits every
check, which is exactly the shape of bug that reaches a member first.

the registry is now rebuildable the same way the hono app is: CORE_REGISTRY
holds the platform's own, CAPABILITIES is core plus whatever the installed
plugins declare, and setPluginCapabilities replaces the plugin half wholesale
rather than diffing it. two invariants hold by construction — DEFAULT_ROLE_-
CAPABILITIES and CORE_CAPABILITIES derive from CORE_REGISTRY, so a plugin can
never put itself in the fresh-install baseline and can never become `core`
(every account, undeniable). a key colliding with a core one is refused and
logged, because a plugin able to redefine `chat` could widen it.

ownerOnly maps to admin, everything else to app. those are the only kinds a
manifest can express, and it has no field for a kind at all.

capabilities are registered BEFORE routes are mounted: the gate runs ahead of
every router, so mounting a route whose permission is not yet registered would
403 the freshly installed plugin until something else happened to refresh.

SECOND: nothing mounted plugins at boot. honoServer is built with none at
import, because discovery reads disk and database and neither can be awaited at
module scope, and every install verb rebuilt — so it tested perfectly and would
have silently unmounted everything on the first restart.

server.tsx now refreshes before serve(), so there is no window where an
installed plugin 404s, and a plugin that will not load is logged rather than
fatal.

verified: after a restart, [plugins] mounted /example, the row survived,
officer-example came back online from its ecosystem entry, capabilityForApiPath
resolves /api/example/ping to the example capability at kind=app, and it appears
in the owner's grantable list. 756 pass, same 10 pre-existing failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 22:43:49 +00:00
pastilhasandClaude Opus 5 62ee0d1e60 the beat between steps is feedback, not decoration
the comment called it cosmetic and worth being honest about, which reads like an
apology and invites the next reader to delete it as a pointless sleep.

the real reason is better. some of this work is genuinely slow — pm2 start
measures ~770ms — and some is effectively instant. without a pause the fast
steps land in one frame, the log jumps from empty to finished, and you cannot
tell 'it worked' from 'nothing happened'. the interval is what makes a step
something you saw happen rather than something you found already done.

only applied when something is listening, so the json path still runs flat out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 22:33:53 +00:00
pastilhasandClaude Opus 5 4d4606d4a2 close the owner's dotfiles once somebody else has a shell
ubuntu's default umask is 002 with user-private groups, so everything the owner
creates lands 775/664. alone on a machine that is harmless. it stops being
harmless the moment a member has a login — and member homes are NESTED inside
the owner's, so the owner's home must stay traversable AND readable (the
ancestor-read requirement bun exposed today) and every dotfile in it is legible
by default.

measured as green before writing this: ~/.pm2/logs (all 12 files, every log the
platform has written), ~/.pm2/dump.pm2, ~/.claude/projects (names every
directory the owner works in), ~/.config, ~/.local, ~/.cache, ~/.npm, ~/.bun,
~/.opencode — all listable. assertSecretsClosed was already holding the line
that matters: .env, .ssh, .zsh_history, .claude.json and the credentials are
denied, and dump.pm2 turned out to hold no secret values because bun loads .env
at runtime rather than through pm2.

so this is the tier below fatal: not tokens, but logs and the shape of the
owner's work.

it runs from PROVISIONING, not from setup, and that is the point. ~/.claude does
not exist until the agent has run once; a chmod at install time finds half the
list missing and silently does nothing — the same failure mode as the ACL mask
earlier today. every member's arrival re-closes whatever appeared since.

two directories are left open on purpose, and both are the same latent bug:

    /usr/local/bin/bun -> /home/pastilhas/.bun/bin/bun
    /usr/local/bin/gh  -> /home/pastilhas/.local/bin/gh

system-wide tools installed into one user's home, so every member resolves them
through it. i found this by closing them and breaking bun and gh for green.
~/.local/share and ~/.local/state ARE closed; only the bin directory is
reachable. the honest fix is installing them outside the owner's home.

verified both directions on this host: green is denied .claude, .pm2, .config,
.local/share, .local/state, .cache — and still has working bun, gh, psql, their
own claude, and their own project tree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 21:59:45 +00:00
pastilhasandClaude Opus 5 a220342b22 stream the install, so it reads like a log instead of a spinner
each verb now reports its steps as they complete, over server-sent events, and
the detail panel renders them arriving.

POST rather than GET, so EventSource is unavailable — it sends no Authorization
header and these routes are owner-only. The client reads the body and parses
frames by hand, which is what useCompanionLogStream already does for the
headscale container logs; the parser only has to understand what our own
endpoint emits.

the runner does not know whether anyone is listening. it takes an optional
onStep and calls it, so the non-streaming path is the same code with no callback
rather than a second implementation of the same four verbs.

there is a 220ms beat between steps and it is cosmetic — worth saying out loud.
pm2 start genuinely takes ~770ms, measured, but writing a row and rebuilding the
router do not, and four lines landing in one frame look like a stall followed by
a jump. small enough not to matter to a script, long enough to follow.

writing to a closed stream is caught rather than fatal: navigating away
mid-install must not abort the install, because by then it is the server's work
and half an install is the one outcome the ordering was designed to avoid.

verified over the wire with timestamps — frames arrive incrementally, the
sidecar step showing its real duration rather than the beat. afterwards pm2
holds the five core apps, plugin_installs is zero, and ecosystem.config.cjs is
byte-identical.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 21:50:50 +00:00
pastilhasandClaude Opus 5 02e049cae8 terminal: stop replaying questions, stop opening two sockets, bind the word keys
three separate faults behind "reconnecting gets weird and the keyboard is not
natural".

── the replay typed into the shell ──

the pty buffer was stored raw and replayed verbatim on every re-attach. anything
in it that ASKS the terminal a question — DSR, DA, DECRQM, XTVERSION, XTGETTCAP,
the OSC colour queries — got asked again, and xterm answered correctly by writing
the reply to its input. the pty receives that as a keystroke nobody typed.

stripped on the way IN, since the buffer is the thing that gets replayed and a
live client already answered them once when they were legitimately asked. only
questions are removed; everything that draws is untouched. where a control shares
its final byte with one that draws, the parameter is enumerated rather than
wildcarded — CSI 18 t asks the window size, CSI 22 t pushes the title, and
stripping the second would change what a replay renders. 36 tests, both
directions, because both fail silently.

── two sockets on one session ──

handleClose armed a reconnect timer; handleVisibilityChange fired on tab focus
whenever readyState was CLOSED — which is exactly what a pending timer leaves.
both ran. every keystroke went twice, two replay frames fought over the screen,
and only one socket was ever cleaned up because __terminalCleanup is overwritten
by whichever connect ran last. connect() is now the single guard, and a stale
socket's close no longer speaks for the session.

── the keyboard ──

alt-arrow was dead for everyone: xterm.js 5 rewrote it into the ctrl-arrow
sequence, xterm.js 6 removed that rewrite and emits the honest ^[[1;3C/D
(verified — the string 1;3D does not appear anywhere in the 6.0 bundle). nothing
bound it. so it broke on a dependency bump, with no shell config changed.

bound in zsh rather than translated in the browser, deliberately: tmux.conf
claims M-Left/M-Right for pane switching, and a client-side rewrite would send
^[b to tmux and break it. the real sequence lets tmux handle it inside a session
and zsh outside.

ctrl-arrow was worse and more embarrassing: it worked for MEMBERS and not for the
OWNER. shell-skel/zshrc has had the bindings all along; the owner's .zshrc is
assembled in machine-setup and never got them. the owner had a strictly worse
shell than the accounts they provision. confirmed with `zsh -i -c bindkey`
before and after.

also: escape-time 10 in tmux.conf. the 500ms default delays every Alt chord and
every Escape, which is most of what "not natural" felt like.

applied to this host by hand — setup only runs at install. cmd+arrow is left
alone: xterm emits nothing for it, so there is no sequence to bind.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 21:38:27 +00:00
pastilhasandClaude Opus 5 2634df7a04 the install runner: ecosystem entry, sidecar, row, mounts
closes the hole app-store/pm2.ts has carried since 2026-08-13 — "installing a
plugin has to append its entry here before starting it, that is the plugin
system's job and it is not built". this is that job, and it is why nothing in
the app-store catalogue installs end to end either.

verified against a running server with a sidecar in the tree:

  install    ecosystem added · sidecar started · recorded · mounted /example
             route 200, pm2 online
  disable    sidecar stopped · unmounted
             route 404, pm2 stopped
  enable     sidecar started · mounted
             route 200, pm2 online
  uninstall  record removed · unmounted · sidecar stopped, deleted, entry gone
             route 404, not in pm2, tables untouched

afterwards ecosystem.config.cjs is byte-identical to before, pm2 holds the same
five core apps, and plugin_installs is back to zero rows.

the ecosystem file is edited rather than regenerated: the core entries come from
officer-setup's shell array, so the platform does not know that list and a copy
here would be a second thing to drift. the header above module.exports is
preserved verbatim too — officer-setup's explains that bun auto-loads .env from
the working directory and that data-path derives the install root from its
PARENT, so a wrong cwd relocates the whole install rather than failing. losing
that to a plugin install would be a poor trade.

order is the design. bringing up goes outside-in, taking down goes inside-out,
so the worst intermediate state is "recorded but not running" — visible, and
fixed by a retry — never "running but forgotten", which nothing can see.

each verb returns what it actually did, in order, and the detail panel shows it.
an install that mounted routes but could not start a sidecar is a different
outcome from one that worked, and a spinner that stops cannot say which.

the schema push is still deliberately not wired, and the reason is now in the
code: db:push DROPS tables absent from the schema it is given, so an uninstall
that regenerated the barrel would delete a plugin's data as a side effect of
stopping it. offscale does not need it — headscale_servers already ships in the
platform schema.

720 pass, same 10 pre-existing failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 21:38:21 +00:00
pastilhasandClaude Opus 5 ed195e0904 record what got built tonight
the plugin system works end to end for a plugin with an api/router.ts, at
runtime, with no restart. what is wired, what is not (schema push, the sidecar's
pm2 entry, websocket providers, totality across plugin routes), and what was
deliberately left: offscale is not extracted, because moving it deletes working
code across ~50 files and that wants someone watching.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 20:28:18 +00:00
pastilhasandClaude Opus 5 98c400bf33 a /plugins screen to install, enable, disable and uninstall
the management surface for what the last commit made possible. two panels either
side of a selection that lives in ?selected= and is read by both independently,
so neither can be telling the other something stale — rows are real Links, not
buttons holding the name in a closure.

the detail panel shows what the tree declared (api, schema, sidecar, web),
because "installed and nothing happened" is otherwise a mystery, and it names
what uninstall does NOT do: neither disable nor uninstall deletes anything the
plugin stored, and the screen says so rather than leaving someone to guess
whether a button destroys their data.

a directory whose manifest will not parse is listed with its error rather than
skipped. a malformed plugin that simply does not appear is indistinguishable
from one nobody wrote.

`outdated` is surfaced as an Update button: the version on disk moving after an
install is the normal state on a developer's machine, and it should be visible
rather than inferred.

the four mutations are written out rather than generated in a loop — useMutation
is a hook, and a hook called from inside a helper is a rules-of-hooks violation
even when the call order happens to be stable. caught before it shipped.

verified against a running server: the spa builds (19.8 MB bundle containing the
new screen), / serves 200, /api/plugins answers authenticated and 401s without a
token. full suite 719 pass, same 10 pre-existing failures. live server and
plugin_installs left untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 20:27:58 +00:00
pastilhasandClaude Opus 5 282a64a637 plugins install, enable, disable and uninstall at runtime
the rest of the mechanism, and it works end to end. against a real server, with
no restart at any point:

  /api/example/ping BEFORE install   404
  AFTER install                      200  {"plugin":"example","ok":true}
  AFTER disable                      404
  AFTER enable                       200
  AFTER uninstall                    404
  core route throughout              200

plugin_installs is a new table rather than a reuse of sidecar_installs. that one
belongs to the app store's model, where installing means provisioning a
container or pointing at a remote instance, and it carries mode, compose_dir and
completed_steps to say so. a plugin install has none of those, and reusing it
would have meant a `mode` that lies about every plugin. the two models coexist
until the app store is rebuilt on this one.

the row is needed because presence is not installation: plugins live in the
repository, so a developer writing one has the directory there and has installed
nothing. the tree says what could run, the table says what does.

mount.ts joins the two and rebuilds. an install row whose directory has gone is
dropped from the snapshot rather than reported — but the row is left in the
database, because deleting it there would turn "somebody moved the checkout"
into silent data loss. a plugin whose router will not load stays unmounted and
says why, rather than taking the other nine down with it.

/api/plugins is owner-only in its own right, like /api/app-store, and its
capability guards the MANAGEMENT surface only — a plugin's own permissions come
from its manifest, so a member can hold one at read without being able to
install anything.

plugins/example is the reference implementation and is meant to be read: the
smallest thing that is still a real plugin, with the directory layout as its own
documentation.

not wired yet, and marked [open] in the router: the schema push and the
sidecar's pm2 entry. a plugin with db/schema.ts or sidecar/ needs both before it
works end to end.

full suite: 719 pass, same 10 pre-existing failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 20:22:18 +00:00
pastilhasandClaude Opus 5 0701aba902 brotli in the core package set
installed on this host already; verified round-tripping from a member shell.
same package name on apt, pacman and dnf. on brew it is there because macOS
ships the library but not the CLI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 20:16:56 +00:00
pastilhasandClaude Opus 5 2e6c263751 the hono app is built, not assembled once
first piece of the plugin system: the platform can now be rebuilt with a
different set of plugins mounted, at runtime, without restarting.

hono cannot do this the obvious way. its default SmartRouter throws "Can not add
a route since the matcher is already built" the moment a route is added after
serving begins, RegExpRouter does the same, and hono has no api to REMOVE a
route at all — so uninstall was impossible even with TrieRouter, which does
allow adding. tested all four.

so nothing is added to a live app. buildHonoApp(plugins) constructs a fresh one
and honoServer is reassigned, which keeps the default fast router and makes
uninstall expressible. server.tsx now serves it through a closure rather than
the bound honoServer.fetch — that one line is the whole mechanism, since the
bound method would capture whichever app existed at serve() and every rebuild
would silently do nothing.

buildHonoApp is pure: everything it needs arrives as an argument, so an app for
a hypothetical plugin set can be built without a database, a filesystem or a
running server.

alongside it, discovery. plugins live at platform/plugins/<app-name>/ — inside
the repo, because bun links the workspace packages into the root node_modules
and that is what lets a plugin author write `import { useClient } from
'hooks/useClient'` with no publishing and no version negotiation. verified with
Bun.resolveSync from a directory there.

discovery is by convention and presence is the declaration: api/router.ts,
db/schema.ts, sidecar/index.ts, web/Router.tsx. the app name comes from the
directory, so it cannot disagree with where the code sits, and the sidecar
runtime comes from the extension — .mjs is node, .ts is bun — which is already
the rule here and cannot contradict the file it describes.

a broken plugin is collected, never thrown: one unreadable manifest must not
stop the boot or hide the nine beside it that are fine.

verified by booting the refactored server on a spare port — /api answers 200,
protected routes still 401. full suite: 719 pass, and the same 10 failures as
before this change (8 in capabilities, plus cliamp and pty), stash-verified
earlier as pre-existing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 20:16:11 +00:00
pastilhasandClaude Opus 5 b2349b5480 install the psql client, matching the server it talks to
there was no psql on this machine. the server runs in a container, so nothing
ever put a client on the host, and `docker exec officer-postgres psql` is the
owner's tool — a member has their own Postgres role and no access to the owner's
Docker socket.

the version is derived from PG_IMAGE rather than typed again, because the
pairing is load-bearing: pg_dump refuses a server newer than itself, and Ubuntu
24.04 ships client 16 against this 18 server. so the archive package is not
merely old, it is unusable for dumps. that is also why this sits beside the
server definition instead of in machine-setup's package list — one constant, one
place to bump.

PGDG added the same way docker.sh adds Docker's: key in its own file, one
sources.list.d entry, no add-apt-repository. non-fatal, and the exit status is
not the gate — apt can succeed while holding an older client back, so the check
is that psql is present AND is the major we asked for.

installed by hand on this host already: psql/pg_dump 18.6, verified as green
connecting with their own role.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 20:13:09 +00:00
pastilhasandClaude Opus 5 0ae0a5dc58 music is where the richer permission model gets designed
offscale is deliberately the simple case — one shared resource, read or write.
music is the next extraction and the right place to build the in-plugin
visibility system, because it has real per-user data (favourites, playlists,
now-playing) on top of a real shared one (a single global library index). so
'whose is this row' has a non-uniform answer there, where offscale's is just
'the owner's'.

not designed yet and deliberately not designed here. recorded so the intent
survives the gap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 20:06:55 +00:00
pastilhasandClaude Opus 5 acd51c969c the platform grants read or write; richer rules belong to the plugin
the platform's contract is what it already has: a role holds read or write on a
capability, stored in role_capabilities and enforced by the gate. anything
beyond — who sees whose rows, per-user isolation, record ownership, visibility
of any kind — is the plugin author's job, inside the plugin. the platform should
not grow machinery for it. a plugin knows what its data means; the platform only
knows whether this account got through the door.

offscale v1 uses that exactly. one shared resource: read sees what the owner
sees, write can change it including deleting a server the owner registered. that
is dangerous on purpose — the stored credential is a headscale admin key with no
read-only equivalent, so write is close to full control of the tailnet, and that
is the owner's call. expected use is read for most roles.

two consequences, both inside the plugin. the queries stop scoping by the caller
and resolve to the owner's id, leaving the per-user shape in the table unused as
the seam if isolation is ever wanted. and two POSTs are really reads —
/ssh-test probes and /policy/assist explicitly never saves — so they need
readOnlyWrites, or a read-level account finds a broken feature where a withheld
permission should be.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 20:04:18 +00:00
pastilhasandClaude Opus 5 4c3682dae6 let a member read the two directories above their own home
`bun run` from anywhere inside a member's home died with

    error loading current directory
    error: An internal error occurred (CouldntReadCurrentDirectory)

before it looked at package.json, bun.lock or .git — all of which were present.

it is not walking up looking for a workspace root. it primes its resolver cache
by walking DOWN from / and opening every component of the cwd for READING:

    openat("/home/pastilhas/officerdev/")              = 6
    openat(".../officerdev/data/")                     = -1 EACCES
    openat(".../officerdev/data/<email>/")             = -1 EACCES

those two are 711 — traversable, not listable — which is enough to cd into a
home and not enough for a program that reads its ancestors. `getcwd` succeeds;
the ancestor read is what fails. `O_PATH` would need only `x`, so this is
arguably bun's bug, but it presents as a member's project being mysteriously
unbuildable and nothing here can fix it from the other side.

so DATA_PATH and the account dir now carry a named ACL entry per member. that
gives up the property the old comment named — a member can now `ls` DATA_PATH
and learn the other accounts' email addresses — and keeps everything that
matters: every home is still 700 and owned by its member, every platform
sibling still 700 and owned by the service user. verified as green: the account
list is visible, and email_accounts, another home, the repo .env, the owner's
ssh key, .pgpass and ~/.claude/.credentials.json are all still denied.

the mask is set explicitly to rx alongside the entry. chmod recomputes the mask
from the group bits, which for 711 is --x, so without that the next member's
provisioning would silently clamp every earlier member back to traverse-only.

applied by hand to the one existing account; provisioning covers new ones.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 20:00:29 +00:00
pastilhasandClaude Opus 5 56bb383c6d a plugin installs with no questions unless it says otherwise
offscale needs none of the install fields the catalogue carries — no modes, no
existingFields, no configFields, no composeTemplate, no members. nothing to
provision, nothing to point at. install is put the code there, push the schema,
start the sidecar, swap the routes, and it is available.

configuration happens afterwards inside the app, which is already how headscale
works: a server is registered at runtime and lands in offscale_servers.

so no-questions is the default rather than offscale's special case, and the
prompting machinery gets designed against the first extracted plugin that
actually needs docker or a remote instance. part of why this was the right
pilot — it exercises mounting, schema and sidecar without install being a
variable at the same time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 19:48:15 +00:00
pastilhasandClaude Opus 5 327783532e reach a member's transcripts by listing them, not just by reading them
yesterday's fix routed transcript CONTENT through the member's identity and
stopped there, on the strength of a comment in ChatIdentity saying enumeration
never needed it — "their directories are 775 and the platform holds an ACL
entry, so readdirSync and statSync have always worked".

there are no 775 directories on this path. claude creates ~/.claude/projects/
and every project group at mode 700, and a 700 directory clamps the ACL mask to
--- exactly as a 600 file does:

    user:officer:rwx    #effective:---
    mask::---

measured against a real member home:

    existsSync(projects)        -> true     (stat only needs traverse on .claude)
    readdirSync(projects)       -> EACCES
    existsSync(projects/<slug>) -> false
    statSync(<transcript>)      -> EACCES

existsSync answering false rather than throwing is why this was invisible: every
caller read it as "no such session". one root cause, three reported symptoms —
an empty conversation list, no title on a new chat, and a /chat/<id> deep link
that never restored the conversation. a fourth nobody had reported yet: delete
removed nothing and still answered ok, because unlink needs w+x on the group
directory too.

so enumeration goes through the same door as content, as ONE call rather than a
spawn per entry: listTranscriptsAs runs a single `find` as the member and
returns every transcript with its mtime, which readdir+stat could not do without
dozens of setpriv forks per request and a matching pile of auth.log lines. the
owner keeps a fork-free path — that process already IS the owner. removeAs does
the same for unlink, and readTailAs no longer stats a file it cannot stat.

summarizeTranscript now takes the mtime it is given instead of stat'ing again,
which is both the fix and one less syscall per file.

verified against jg@pertento.ai on this machine: 6 conversations listed with
titles from their first prompts, and a deep link by id alone loads 71 messages.
owner path re-checked and unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 19:48:06 +00:00
pastilhasandClaude Opus 5 9f903479ce websocket routes reload too, so nothing needs a restart
the last gap. six ws providers live in bun's route table rather than hono's, so
the app swap does not reach them — but server.reload({routes}) does, and in both
directions: refused before, connected after install, refused again after
uninstall, with core routes untouched throughout.

so a plugin can own a socket from the start, and no part of an install needs the
process restarted.

still untested: whether connections already open across a reload survive it.
that matters before an install is allowed to interrupt somebody's terminal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 19:46:07 +00:00
pastilhasandClaude Opus 5 6ab838c77f mounting at runtime after all: rebuild the app and swap it
this went round twice — runtime dynamic, then generated-plus-restart on the
belief that hono could not mount after serving, then back once that was actually
tested. the doc keeps the route rather than just the destination.

tested: SmartRouter (hono's default) and RegExpRouter both throw 'Can not add a
route since the matcher is already built'. TrieRouter and PatternRouter accept
it. so runtime adding is possible but costs the fast matcher, and hono has no
remove-route api at all, which uninstall needs.

what solves both is not adding routes but rebuilding: construct a fresh app from
the current plugin set and reassign the variable. the fetch closure reads it per
request, so the reassignment is the swap — atomic, no dropped connections, no
server.reload, and the default SmartRouter is kept. verified 404 before install,
200 after, 404 again after uninstall, with core routes unaffected throughout.

the mechanical cost is one line: server.tsx:322 is '/api/*': honoServer.fetch, a
bound method evaluated once at serve(), and has to become a closure or the swap
does nothing.

websockets stay open: six providers live in bun's route table rather than
hono's, so a plugin owning a socket needs server.reload({routes}), untested.
offscale has none.

and totality stops being a boot check — buildApp() is now the single place
routes are mounted, so it is where the assertion belongs, refusing the swap
rather than refusing the boot.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 19:45:22 +00:00
pastilhasandClaude Opus 5 13437e0e48 mounting: generated then restart, reversing the call for runtime dynamic
this reverses the earlier decision for C (mount and unmount at runtime) and says
so rather than quietly overwriting it.

the requirement behind C was that the platform must not need to know a plugin in
advance. that is met either way: what it reads is a generated file listing the
installed routers, analogous to Plugins.tsx on the frontend — nothing hardcoded,
nothing read from a table at boot, the imports made concrete at install. C would
have bought only the absence of a restart.

and a restart is close to free here, because sidecars are pm2 peers rather than
children — a property that was fought for, since officer used to spawn the agent
and pm2's tree-kill took the owner's chat down on every restart. what a restart
costs is websockets, which reconnect, and in-memory session records, which
claude:list already recovers.

the happy consequence is that assertCapabilityTotality stays a boot check
instead of becoming a per-mount transaction. it does need to be fed the route
table rather than Object.keys(handlers) first — generated mounts widen that gap
rather than closing it, so that is a prerequisite and not a tidy-up beside it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 19:35:55 +00:00
pastilhasandClaude Opus 5 7befaf032a the manifest holds only what the tree cannot say
lean it to identity facts and human choices: publisher, version, platform range,
the four presentation fields, and permissions. everything structural becomes
convention, where presence is the declaration — sidecar/, api/router.ts,
db/schema.ts, web/Router.tsx, web/panels.ts. appName comes from the directory
name, so the id cannot disagree with where the code sits.

the dock tile and page title needed no fields at all: the tile is label + icon +
color + mountPrefix, and the title is label. writing them again was duplication
that could only drift.

runtime is the file extension. index.mjs is node, index.ts is bun — implicit,
but already the rule here, since officer-pty runs under node for node-pty's abi
and everything else is bun. better than a field that can contradict the file.

dependsOn is gone; nothing read it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 19:32:07 +00:00
pastilhasandClaude Opus 5 7ebc4d0ccd note the totality/route-table drift for later
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 19:27:54 +00:00
pastilhasandClaude Opus 5 1292a5c5ab opencode is owner-only until it carries an identity
a turn on the opencode harness ran as the owner, in the owner's home, whoever
asked. handleOpenCodeChat resolves its cwd against getOwnerHomeDir(email), which
discards the email it is given, and the sidecar runs one shared `opencode serve`
as the service user — sendOpenCodeStreaming accepts userId/email/username and
forwards none of them. it carried a comment calling itself owner-only; nothing
enforced it.

reachable by any account with the `chat` grant, which every role holds by
default (DEFAULT_ROLE_CAPABILITIES), and isClaudeModel is a startsWith, so a
typo'd model string landed there too. the model is client-supplied and never
checked against the catalogue.

the same gap on the read side: opencode's session store has no per-user scoping
at all, so loadOpenCodeSession/delete/rename take an id and no identity, and the
list and live routes returned other people's conversations.

so: ChatIdentity carries isOwner as its own fact (not inferred from
osUser === null, which holds only while resolveHomeDir refuses a member without
one), and every opencode door in chat.ts checks it — list, load, live, delete,
rename — plus a refusal on the execution path in handleChat. /chat/models hides
opencode from non-owners as a courtesy; the socket refuses regardless.

a stopgap, not a design. the fix is to thread identity through the opencode
sidecar the way spawnClaudeAsMember does, and TODO.md has been saying so.

not fixed here, and worth knowing: a member's session list is still empty and
/chat/pwds still 500s, because readdirSync on their ~/.claude/projects is EACCES
— claude creates it at mode 700, which zeroes the ACL mask. visible in
officer-error.log right now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 19:27:54 +00:00
pastilhasandClaude Opus 5 4dc7cd90c2 a plugin declares permissions, not capabilities, and has no kind
'capabilities' already means three things in this codebase — the permission
registry, the officer-items store, and the sidecar's routing keys. a fourth
would be one too many, and the field is really just permissions. the name is
free: the old permissions table went in 044aacf4.

and the kind enum is gone with it. the first draft handed a plugin the
platform's own five-value CapabilityKind and then forbade three of them. those
five exist because the platform has five sorts of surface; a plugin has two —
grantable to members, or owner-only. a boolean says it, and says it without
needing a prohibition: a plugin cannot claim core if core is not a word it can
say.

offscale is ownerOnly: true, which is what kind: 'admin' meant.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 19:25:54 +00:00
pastilhasandClaude Opus 5 b18601530f a manifest for offscale, and the rule it immediately broke
written against the real plugin rather than invented as a field list, on the
theory that an abstract one includes what nothing needs and misses what is
awkward. that paid off on the first field that mattered.

the rule here said a plugin may declare `app` and nothing else. offscale's
capability is `admin` — owner only — and should stay that way, so the rule was
wrong. the distinction is direction, not privilege: `core` means every account
and not deniable, so claiming it grants yourself to everyone; `admin` means
owner only, which is a plugin restricting itself. corrected table in the doc.
core, execution and confined stay the platform's to assign.

`publisher` is the only input to the mount prefix, through one function, so
first-party and third-party cannot drift into two code paths.

sidecar.runtime is a field because officer-pty needs node for node-pty's abi
while everything else is bun — one plugin already needs it, so not speculative.

dependsOn is informational and unenforced. code dependencies need no declaration
now that a plugin builds inside the workspace, and service dependencies already
degrade; this exists so the store can say the console section wants the terminal
plugin, rather than the section silently doing nothing.

health is marked deferred rather than open, with the reasoning, so it does not
get re-raised. migrations likewise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 19:05:15 +00:00
pastilhasandClaude Opus 5 f6b2905cc7 how the frontend ships, and what plugins may depend on
everything moves to the plugin, frontend included, so federation stopped being
a later problem and had to be answered. it is answered by not needing it: bun
builds the spa into build/ at start and rebuilds it on install, serving from
that directory instead of compiling through the html import. Bun.build is a
runtime call, so an install needs no restart — just a refresh. same origin
throughout, which is why there is no cors work and no rewrite of useClient.

App.tsx keeps core routes and gains one map over `plugins`, each mounted at a
wildcard delegating to the plugin's own router. that list comes from a generated
Plugins.tsx, because a bundler cannot follow import(runtimeString) — the
specifier has to be concrete before the build. the six places the shell
currently hardcodes headscale collapse into that one file, dock included; the
runtime dockItemsFromPlugins path follows rather than competing with it.
presentation moves to build time, permission stays runtime.

dependencies turned out to be two different problems wearing one word. a service
dependency (assist → anthropic-proxy) is a wire call and already degrades. a
code dependency (ConsoleView → TerminalView) is in the bundle and cannot. rule:
may depend, must degrade. service calls go through the api carrying the user's
token, with the user's own permissions, which also deletes the state-file read
claude-proxy uses today to lift the proxy's secret.

no per-plugin permission list: a plugin is part of the app and bounded by the
account calling it. that makes marketplace review a security boundary rather
than a naming one, which is worth knowing rather than discovering.

and the developer environment is a platform checkout — clone it, run dev, build
the plugin inside. the 13 workspace packages resolve by name because bun links
them, so `import { useClient } from 'hooks/useClient'` just works with no
registry and no versioning. dev-time and build-time become the same mechanism.

also writes down the headscale inventory now that it has been read end to end,
including that assist.ts travels unwired as a marker and must not be tidied away
as dead code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 18:58:22 +00:00
pastilhasandClaude Opus 5 7f26f0b4b8 offscale is headscale plus the companion, not a rename
the name looks like branding on someone else's project, which is exactly how it
gets 'corrected' back later. it is not: offscale is the stock headscale server
plus the companion that ships beside it, and the invite flow is the first thing
that only exists there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 17:44:28 +00:00
pastilhasandClaude Opus 5 01a20fff4e the invite flow replaced device enrolment; it is not a gap
closes the one open item left by deleting /api/vpn. removing the vpn capability
leaves no member-grantable headscale surface and that is correct: the owner
mints an invite from the headscale app, the companion turns it into the redirect
the phone claims, and the device joins. no per-member permission on officer is
involved at any step.

recorded as decided rather than open so nobody reintroduces a member-facing
enrolment route believing something was lost. nothing was — /api/vpn/enroll was
the design the invite flow replaced, and it never had a UI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 17:43:42 +00:00
pastilhasandClaude Opus 5 88a44ec4a7 delete /api/vpn
it had no caller. verified three ways before removing: nothing in the mobile
monorepo reaches it (enrollVpn's only call site is behind `if (embedded)`, and
the one app rendering VpnScreen never passes embedded), nothing in the officer
web app references it, and the live database holds no vpn grants. the companion
was checked separately by its own author — zero references there either.

and it will not come back. offscale is permanently standalone: the thing that
gets you to the platform cannot itself need the platform, or a broken tailnet
locks you out of both.

gone: api/vpn/router.ts, its mount, and the `vpn` capability. the registry keeps
a comment where the capability was, because its removal has a cost worth
recording — headscale is admin-only, so no member-grantable headscale surface
remains, and reintroducing one is a deliberate act rather than an oversight.

kept: the sidecar's enroll.ts. its bare POST /_officer/enroll handler is now
unreachable, but the file is also the dispatcher for /enroll/invites, which is
live and fundamental. the header comment now says so, so nobody deletes it
looking for dead code.

also records the third component in the doc. two of the three have an "enroll"
surface and only one is ours: /api/v1/enroll/* belongs to the companion, is
where the phone actually goes, and must not be collapsed into /api/offscale/*.

capabilities tests: 17 pass / 8 fail both before and after, stash-verified — the
8 are pre-existing, in totality and path-to-capability, which is precisely the
machinery dynamic mounting will rework.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 17:40:44 +00:00
pastilhasandClaude Opus 5 bbc60b34ac headscale leaves the baseline, and offscale gets a design doc
first step of extracting headscale into a plugin. CORE_PROCESSES is five now,
and the catalogue.test CORE[] mirror follows it — not optional, since that list
asserts "the catalogue must not offer a core process" and would have blocked
adding offscale to the catalogue later.

the local generated ecosystem file lost its entry too, and officer-headscale was
stopped and deleted from pm2 by hand. the platform still mounts /api/headscale
and still declares the headscale and vpn capabilities, so the feature is
present-but-unavailable rather than gone.

docs/offscale-plugin.md is a live document for the rest of it. what it records
that nothing else does: core is now `officer` alone and everything else is a
plugin; routes are /api/<app-name> for ours and /api/p/<creator>/<app-name> for
third parties, derived by one function so the two can never become two systems;
tables stay in public with an app-name prefix; mounting becomes genuinely
dynamic, which retires the "every route stays mounted" premise and relocates
assertCapabilityTotality from a boot check to a per-mount transaction.

it also records a rejected experiment with evidence — a postgres schema per
plugin works completely, including cross-schema FK, idempotent push and
DROP SCHEMA CASCADE as uninstall — and the reason not to: drizzle-kit 0.31.8
needs schemaFilter naming every schema, contradicting its own docs, and without
it push reports "No changes detected" and creates nothing. a plugin install that
reports success and makes no tables is the exact failure shape we have hit three
times this week.

and /api/vpn is dead: no caller in the mobile monorepo, none in the web app, no
grants in the database. offscale is permanently standalone, so it never comes
back. the invite flow is unaffected — the phone claims from the Companion, not
from officer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 17:37:55 +00:00
pastilhasandClaude Opus 5 d000cedf2f let anyone toggle hidden files again
the show/hide dotfiles button was dead for members. they open the browser at
their own home, that home is `/`, and the toggle is disabled at `/`.

it was never meant to apply to them. 74894b0c wrote it as

    user?.role === 'Super Admin' && currentPath === '/'

to keep the OWNER's home root readable — it is all .bashrc and .ssh and
.claude. then 044aacf4 removed the multi-user surface, dropped users.role, and
noted in its own message that "every role === 'Super Admin' check was
permanently true". so it folded the conjunct away and left `currentPath === '/'`.

correct on a single-user server. multi-user came back on 2026-08-07 and this
line did not come back with it, so a rule about one account's home quietly
became a rule about everyone's — and members feel it constantly, because
members are always at their root.

removed rather than restored to owner-only: the point was a tidy default, not a
prohibition, and `files/showHidden` already defaults to false. so dotfiles stay
hidden until asked for, everywhere, for everyone — and the asking now works.

the server never filtered dotfiles; readdir returns them and always did. this
was only ever the client.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 16:11:05 +00:00
pastilhasandClaude Opus 5 336e718463 read a member's transcripts as the member
a provisioned member could chat normally and had no conversation list. every
refresh came back empty, so nothing could be resumed, and a new chat never
became a saved one.

nothing was wrong with the logic. the turn runs as them, writes its transcript
into their home, and the platform looks in exactly the right place — it just
cannot read what it finds.

confineUserTree grants the service user a named acl entry on every member home,
with d: defaults so anything created later inherits it. that entry is real and
getfacl shows it. it does not survive a file created at mode 600, because posix
derives the acl mask from the group bits of the creation mode:

    user:officer:rwx    #effective:---
    mask::---

claude writes every transcript at exactly that mode — .claude and projects/ are
775, every *.jsonl is 600. so readdir and stat worked, every read raised eacces,
and summarizeTranscript catches eacces and returns null. the sessions did not
fail, they vanished.

no acl can fix this. the creation mode ands the mask down, so d: defaults cannot
raise it, and the only way up is through `other`, which is every account on the
box. a 600 file has two readers: its owner, and root.

so read as the owner of the file, through the same runAsArgv the terminal and
the agent already use. spawnSync keeps it synchronous, which is what lets it
drop into a 914-line synchronous parser reached from five modules instead of
rippling await through all of it.

the privileged surface turned out to be seven call sites, not the file: stat
needs traverse and readdir needs read, and the 775 directories give both. only
content needed identity.

also fixes a 500. parseClaudeTranscript read the file uncaught after an
existsSync that passes, so deep-linking /chat/<id> as a member threw rather than
404ing. it returns null now, like the list path always did.

verified against a throwaway linux account provisioned the same way a member is
— 700 home, named acl, transcript written as them at 600. before: 0 sessions and
loadClaudeSession null. after: the session, its title, its messages, and a
rename that leaves the file owned by the member at 600.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 15:58:36 +00:00
pastilhasandClaude Opus 5 fe0012635a stop leaking the parent claude session into the one we spawn
pm2 inherits the environment of whoever ran pm2 start, so restarting this
sidecar from inside a claude code terminal — which is how it is restarted
most of the time — bakes that terminal's session into the daemon. right now
this process is carrying CLAUDE_CODE_MESSAGING_SOCKET for an unrelated pid
that has been alive for an hour and a half.

three of these were already stripped; the rest arrived with 2.x and were
never added. this is hygiene, not the fix for today's hang — a spawn was
verified to succeed with the whole set present — but a child attaching to a
stranger's ipc socket is not a failure anyone would recognise from the
symptom.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 15:36:32 +00:00
pastilhasandClaude Opus 5 547662842b a dead claude process no longer hangs the chat forever
the sdk runs two independent tasks per session: the consumer loop
(`for await (const msg of q)`) and an input pump that writes the queue to
the child's stdin. the consumer loop's `finally` is what removes a session
from the map — but when the CHILD dies it is the input pump that fails,
with `ProcessTransport is not ready for writing`, and that rejection
neither ends the consumer loop nor is caught anywhere.

so the loop stayed parked on a stream with no writer, `finally` never ran,
the session stayed in the map, and spawnClaudeStreaming handed every later
turn to the same corpse. each one pushed a message onto a queue nobody
drained: no error, no result, no timeout. the client spun forever and the
only trace was one unhandledRejection line in the sidecar log.

observed on the host today; the only cure was pm2 restart
officer-claude-code.

a member's turn already supplied its own spawn function because it has to
go through setpriv. the owner had none, and therefore no place to observe
the child — which is exactly why its death was invisible. so give the owner
one too, and wrap both in watchChild: on exit or error, drop the session
from the map and, if a turn was in flight, tell the client.

emitting only while generating is deliberate. a child that exits between
turns is invisible to the user, and an error bubble arriving in a chat
nobody is looking at would be noise — dropping the map entry is the whole
repair there, because the next turn builds a fresh session and resumes the
transcript by id.

the stall timer now tears the session down as well. it used to keep it —
"it may still be working, and the next turn resumes it" — which is right
for a slow agent and wrong for a wedged one: the session stayed broken, so
every later turn hung the same way and "send again to continue" was a lie.
sessions with background tasks outstanding are still left alone, since a
job can be silent far longer than ten minutes and still land its
notification.

verified live against the real manager: killed the child mid-turn, saw the
error surface and the next turn rebuild the session.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 15:36:09 +00:00
pastilhasandClaude Opus 5 eb1fd8c31a headscale is core, so stop offering to install it
Reported as: the routes are unreachable and there is no dock tile, on a server where
officer-headscale is up and healthy. Both symptoms, one cause.

Availability is derived ONLY from the sidecar_installs table — `usable` is the rows
with status='installed' AND enabled, and every capability mapped to a sidecar outside
that set is added to `unavailable`. A CORE sidecar never gets a row there, because
core processes are started by pm2 from the generated ecosystem file and never go
through the app store. So `headscale` and `vpn` were permanently unavailable, which
withheld the dock manifest AND put /headscale into deniedRoutes for the route guard.

The design already knew. catalogue.test.ts has a test called "does not offer to
install the baseline", and it has been FAILING since headscale was promoted:

  Expected to not contain: "officer-headscale"

docs/secret-store.md predicted it in as many words — "moving headscale into the light
profile also removes it from the app store automatically: catalogue.test.ts asserts
the catalogue equals full − light, so the test fails until the entry is deleted". The
entry was never deleted, and the failing test was never read.

So: entry removed, and the tile moved to CORE_DOCK_ITEMS, where the other things that
are always present live. DashboardLayout filters every tile through canVisit(), so a
member still never sees it — the capability is kind: 'admin'.

The entry's existingFields (URL + API key) are not lost. Servers are added from the
Servers view inside the app — ServersView.tsx, ServerForm.tsx, useHeadscaleServers.ts
— which is where they were really configured; the app-store form was a second place to
type the same two values.

Verified: catalogue.test.ts 19 pass/1 fail → 20 pass/0 fail, tsgo clean.

PRE-EXISTING, not touched: 10 other tests fail on master, 8 of them in
src/servers/capabilities. Confirmed identical before and after this change by
stashing it and re-running. Worth a look but not this change's business.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 13:31:00 +00:00
pastilhasandClaude Opus 5 d85f089817 tsgo is clean
All five errors gone. Both were real resolution bugs rather than dead code that
happened to be noisy — the unreachable parts were unreachable for the wrong reason.

officerdb's export map gave the wildcard no extension:

  "./types": "./src/types.ts"     explicit entries carry it
  "./*":     "./src/*"            the wildcard did not

so `officerdb/soulseek/schema` resolved to `src/soulseek/schema`, which is not a
file, while `src/soulseek/schema.ts` sat right there. Now "./src/*.ts". Verified
every subpath still resolves at RUNTIME with Bun.resolveSync — an exports map is
exactly the thing where a typecheck fix can break the running app, and three of the
four paths are load-bearing.

types.ts inferred EmailAccount* and PushDevice* from `./schema`, the aggregator that
drizzle-kit reads — where both tables are commented out because they belong to
plugins. But inferring a TYPE has nothing to do with whether the table exists in the
live database: these describe rows the plugin's own code passes around, and that code
compiles whether or not the plugin is installed. Reading them off the aggregator
coupled the two, so commenting a plugin out of schema.ts broke the build of code that
was already unreachable.

They now come from ./email/schema and ./notify/schema directly — the same move the
query modules made when the split landed, and the thing that lets a table leave the
aggregator without breaking anything. src/databases/CLAUDE.md already describes this
as the rule; types.ts was the one file that had not followed it.

Verified: bunx tsgo --noEmit, zero output.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 13:24:32 +00:00
pastilhasandClaude Opus 5 11710f283a wire the reverse proxy in as officer-setup 12
~/npm-setup-draft/setup-npm.sh, adapted to the script's own helpers and placed last
— it is the only step that needs Officer already running.

It ignores --unattended, as asked. Every other question in this script has a
defensible default; a domain name, a DNS provider and that provider's API
credentials do not, and the step is opt-in besides. Its prompts read stdin directly
instead of going through confirm()/ask_required(), and they are NAMED APART
(proxy_confirm, proxy_ask) so nobody later consolidates them into the shared helpers
and quietly makes --unattended agree to publishing a public hostname.

The valve is a TTY check rather than the flag: with no terminal there is nobody to
ask, so it skips and prints the manual instructions. A cron-driven install still
works.

Five fixes to the draft:

  - `${OFFICER_REPO}/scripts/store-npm-credential.ts` — OFFICER_REPO is a git URL,
    not a directory, so that path was https://…/platform.git/scripts/… and the -f
    test could never pass. The whole persist-to-platform branch was dead code
    falling through to the print. Dropped it: the comment beside it already argued
    that not storing this password is a legitimate outcome, since only a human
    logging into the admin UI needs it.
  - NOT re-runnable, despite saying so. claim_admin returned early on an already
    claimed instance without setting NPM_EMAIL/NPM_PASSWORD, and get_token
    dereferenced both under set -u. Second run died on an unbound variable. It now
    asks for the existing credentials.
  - $HOME/dockers → $OFFICER_ROOT/dockers, matching data-path.ts. And the network
    is SETUP_DOCKER_NETWORK (`services`), not a second bridge called `officerdev`.
  - dig → getent hosts. dnsutils is not installed by this platform, so the check was
    command-not-found on a fresh VPS — and an empty answer is indistinguishable from
    "not resolving yet", so it waited the full 30 minutes before failing.
  - python3 → jq for host-side JSON. jq is already in the core package list; the one
    remaining python3 runs INSIDE the NPM container to read its own credential
    template, which is the point of reading it from there.

Failure is contained: every function warns and returns non-zero rather than exiting,
so a proxy that does not come up leaves a finished Officer install behind. Retry
with `--only Proxy`.

Verified: bash -n, shellcheck -S warning clean, --list shows Proxy, all seven
external commands present, and the jq filters checked against sample payloads
including the multi-line DNS credential.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 12:56:40 +00:00
pastilhasandClaude Opus 5 b6feca8350 owner can reset a member's platform password
The gap at the other end of create-user.ts: the owner could set a password once, at
creation, and never again. Losing it meant a hand-written UPDATE with an argon2
hash — the same "edit Postgres by hand" hole that creating accounts used to have.

POST /api/users/:id/password, owner-gated, with a button on the row.

GENERATED, not typed. The failure this exists for is "I created the account and
forgot to copy the password down", and an owner typing a replacement can lose it the
same way on the second go. Shown once in a dialog built to be copied — a dialog and
not a toast, because a toast that times out while somebody finds a pen loses the one
thing they came for.

The generator satisfies validatePassword BY CONSTRUCTION rather than by luck: one
character drawn from each of the four required classes, the rest from the union,
then Fisher-Yates shuffled so the first four positions are not always
lower/upper/digit/special. Rejection sampling throughout — `% n` on a byte biases
the early characters. Then it runs validatePassword on its own output, so if the
rules ever gain a requirement the alphabets do not cover it throws at the one call
site instead of minting passwords the login form rejects. Measured: 20,000
generations, all four classes present every time.

l, I, 1, O and 0 are absent from the alphabets. This gets read off a screen and
typed somewhere else.

Signs them out everywhere, as asked: passwordChangedAt = now, and userMiddleware
already refuses any token whose iat predates it. That overwrites the null
create-user leaves to mean "the owner chose this, not them" — checked, nothing reads
that column except the token check.

The Linux account is deliberately untouched, and the dialog says so. Members have no
Linux password and never had one: ensureOsUser runs useradd with no -p, so it is
created locked. Their terminal goes through setpriv, which does not authenticate;
their SSH is the key the owner pasted; `su - <member>` as root does not ask. And
machine-setup sets PasswordAuthentication no — verified on this host — so one could
not be used to log in even if it existed. Setting one would be a new way in, not a
repair.

The owner is excluded: they have change-password, which asks for the current one,
and resetting themselves here would end the session doing it.

Verified: transpiles, all lucide icons exist, 20k generator runs. tsgo next.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 12:07:43 +00:00
pastilhasandClaude Opus 5 1d95ad3d1b install the rootless docker prerequisites with docker itself
Reported from a member's daemon failing: "rootless Docker needs these packages on
the host: uidmap".

They were being installed — but only inside branch [2] "rootless Docker for
<owner>" in section 22. The owner's choice is not the only one that matters: every
Developer account the platform provisions gets its own rootless daemon whatever the
owner picked for themselves. So on a machine where the owner chose the docker
group, the host never got them and every member's daemon failed.

Moved into install_docker_engine, so they arrive with Docker rather than with one
particular answer to a question about the owner.

Three packages, not the one in the error. checkDockerPrerequisites in
os-user-docker.ts is the authority and wants uidmap (newuidmap, newgidmap) AND
docker-ce-rootless-extras (dockerd-rootless-setuptool.sh); dbus-user-session is
what keeps a member's systemd --user alive without a login session. rootless-extras
is only RECOMMENDED by docker-ce — installed by default, so usually there by luck,
and absent on any host configured with --no-install-recommends. Named explicitly.

Reproduced on this machine while checking: rootless-extras present via Recommends,
uidmap absent, newuidmap and newgidmap missing. Exactly the reported failure, on a
box that chose the docker group.

The rootless branch still installs uidmap and dbus-user-session behind its
pkg_is_installed guard. Redundant now, kept deliberately: it is the only thing that
fixes a machine whose Docker was installed by an older run of this script.

Verified: bash -n on both files, and all three packages present in the noble
archive.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 11:58:23 +00:00
pastilhasandClaude Opus 5 e881015df5 members get the same aliases as the owner
The set that just went into the owner's zshrc, mirrored into shell-skel/zshrc, so a
shell on this machine and a shell in a member's account behave alike rather than
diverging by who you happen to be.

Not a copy-paste. Three differences, each because this file has rules the owner's
appended block does not:

  - `n` and `vim` are NOT repeated. The Editor block above already sets them, and
    only when nvim is actually installed — better than the owner's unguarded pair.
  - the eza family keeps its `else` branch rather than only being guarded. A member
    with no eza still gets a coloured, grouped listing instead of bare `ls`, and
    every alias in the family has a real fallback: lll, lh, ltr and l were added to
    that branch too rather than silently existing only when eza does.
  - lazydocker is guarded like its neighbours duf and lazygit, per this file's
    stated rule that nothing is required beyond zsh itself.

eza needs no separate install for members: they share the host, and machine-setup
puts it in the core package list.

KNOWN, same shape as append_once: seedShellConfig only rewrites .zshrc while it is
still byte-for-byte the template, so a member provisioned before this keeps the old
one. No members exist right now, so nothing to migrate.

Verified: zsh -n, and the fallback branch resolving all ten aliases with eza absent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 11:17:06 +00:00
pastilhasandClaude Opus 5 3862558b92 real aliases for the owner, and eza to go with them
The owner's `aliases` block was one line — `alias sz`. Members got a full set from
shell-skel/zshrc and the owner got that. Replaced with the eza ls family, the
oh-my-zsh standards, and n/vim/sz/ld/httpserver.

eza added to all four core package lists. It is in the noble archive at 0.18.2-1,
so this is a package rather than a binary fetch, and Core utils is section 5 — well
before Shell at 26, so `command -v eza` is already true when the block is written.

The eza aliases are GUARDED behind `command -v eza` and the rest are not, and the
asymmetry is deliberate: these replace `ls`. Unguarded, a machine where eza failed
to install has no working `ls` in any new shell, which reads as a broken machine
rather than a missing package. `alias ld=lazydocker` without lazydocker is one
command-not-found when you type it — that can degrade honestly. Same principle
shell-skel/zshrc already holds to.

python3, not python, for httpserver: Ubuntu ships no `python` binary at all, so as
given it would have been a command-not-found on every machine this targets.

Checked the editor block first — it only exports EDITOR/VISUAL/SUDO_EDITOR, so
n and vim do not collide with anything already appended.

KNOWN: append_once returns 1 when its marker is already present, so a machine that
has already run this keeps the old one-line block and gets none of the above. That
is the function working as designed — it exists so a second run does not duplicate
its work, and it cannot tell a stale block from one the owner edited. Fix by hand:
delete the `# >>> machine-setup: aliases >>>` block from ~/.zshrc and re-run
`machine-setup.sh --only Shell`.

Verified: bash -n, zsh -n on the block, the eza guard leaving ls unset when eza is
absent, and vim resolving through n to nvim.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 11:12:46 +00:00
pastilhasandClaude Opus 5 977e30e782 point the setup default at public https
was  ssh://git@gitea.pastilhas.dev:2222/officerdev/platform.git
now  https://gitea.officer.dev/officerdev/platform.git

Bigger than a URL swap. The SSH default could not clone on a genuinely fresh
machine: the key machine-setup generates there is brand new and Gitea has never
seen it, so `--repo` was effectively mandatory on a first install — which is the
problem that flag was added for two hours ago. HTTPS needs no key and no agent, so
the default now works on a blank box.

The old comment explained SSH-because-private and set the condition for changing
it: "back to HTTPS when the repository is public". It now is — verified with an
anonymous `git ls-remote`, which lists refs with no credentials. Rewrote the
comment to record why it moved and what to do if it ever goes private again, since
that reasoning is the part worth keeping.

clone_repo already runs GIT_TERMINAL_PROMPT=0, so a private repo would fail fast
rather than hang on a username prompt. No change needed there.

repo.sh is still the only place that sets this, and --repo / OFFICER_REPO still
override it. Verified both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 10:11:42 +00:00
pastilhasandClaude Opus 5 edbe446b34 revert "allow port 22 through the docker-user allowlist"
this reverts e36c6bb4. the rule was added to fix gitea ssh on one box, but this
file provisions every machine and most will never run gitea. opening 22 to
containers by default is the wrong trade — the box that needs it can add the
line deliberately.

also restores the accuracy of the prompt in machine-setup.sh, which tells the
operator the rules allow "only 80 and 443".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 09:58:18 +00:00
pastilhasandClaude Opus 5 e36c6bb431 allow port 22 through the docker-user allowlist
the DOCKER-USER chain is the only thing gating docker-published ports from
the internet — docker writes its own DNAT/FORWARD rules and bypasses ufw, so
`ufw allow <port>` has no effect on a published container port. the allowlist
permitted only 80 and 443, so a machine provisioned from this template dropped
gitea ssh silently.

the failure is hard to spot: the port looks open locally and docker ps shows it
published, but external clients hang at TCP connect with no refusal. local tests
pass because they arrive via lo and match the loopback RETURN before reaching the
DROP. comments added so the next person recognises it faster.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 09:51:22 +00:00
pastilhasandClaude Opus 5 4d14e11f6c --unattended: every question that has a default answers itself
51 yes/no prompts and ~20 free-text ones, of which about six actually need a human.
The line drawn is "a question with a default answers itself; a question with no
possible default still asks", so it stays attended without being a conversation.

Half of it already existed: ASSUME_YES=1 was implemented and honoured by confirm()
in both scripts, returning each question's OWN default — so a "do the thing you
asked for" question goes yes and a genuine extra goes no. --unattended sets it.

The new part is menu_answer(), for the eight numbered menus. It sets the variable
EMPTY rather than passing a default in, because every menu already consumes its
choice as `${CHOICE:-<n>}` — the default lives next to the options it selects
between, which is the right place, and a second copy in the helper could drift from
the one the prompt advertises. Verified all eight consume that way before touching
them. `read <<<''` rather than eval or `declare -g`, which is bash 4.2+ and rules
out the bash 3.2 macOS still ships.

officer-setup's ask_required takes its default too, except where there is none — the
owning account on a machine machine-setup never ran on, where a guess would install
as the wrong user.

STILL ASKS, deliberately: the username; the Tailscale control plane, login server
and auth key; the git identity; and an SSH public key when the account has none.
That last one is a trap I nearly walked into — on a fresh VPS KEY_COUNT==0 forces
ADD_KEY=true with no confirm, and the menu's default is "[1] paste a public key",
which then prompts with no default at all. Auto-answering that menu would hang or
fail, so it is excluded by name. adduser also still asks for a password; that is
the tool, not us.

Two pre-existing bugs fixed on the way: machine-setup's sudo re-exec passed "$@"
after `shift` had emptied it, so --only and --reask stopped existing the moment it
escalated — same bug as officer-setup had. And UNATTENDED/ASSUME_YES are named in
all three sudo lists, because env_reset would otherwise drop the flag at
escalation, which is now the fourth variable lost that way.

Verified: bash -n on five files, --help on all three, and menu_answer + confirm
under the flag showing a menu resolving to its default and a no-default confirm
correctly answering no.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 06:46:48 +00:00
pastilhasandClaude Opus 5 4c33ef7206 fix the silent death after installing zsh
Reported from a fresh Hetzner VPS: the run stopped dead right after apt finished
installing zsh, printing nothing at all — just install.sh's "machine setup did not
finish".

install_oh_my_zsh carried a comment saying it "Returns 0 whatever happens". It did
not. Under `set -e` a failing command inside a function aborts the SHELL at that
line when the function is called plainly; `return 0` underneath is never reached.
The command is also `>/dev/null 2>&1`, so the cause was invisible — which is why
the transcript just ends.

`|| true` is what actually makes it non-fatal. The file already uses that idiom
correctly in four other places, so this was a slip rather than a misunderstanding.

set_login_shell had the identical bug on `chsh`, which the same run would have hit
on the very next question. Fixed differently and deliberately: `|| true` there
would let the caller announce a login shell that was never set, so it returns
chsh's real status and the CALLER guards the call — which is also what keeps set -e
out of it. A refusal now reports, names the manual chsh command, and carries on,
because a machine with zsh installed and bash at login still works.

Does not explain WHY oh-my-zsh failed on that host — the output was discarded. It
will now say "oh-my-zsh did not install" and continue, which is enough to see it.

Verified: bash -n on both files, and a reduced case proving broken() exits 1 while
fixed() survives.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 06:23:45 +00:00
pastilhasandClaude Opus 5 6c13e0d8f6 tell the operator they are still root, once
Neither script ever becomes the user it sets the machine up for — a process cannot
change its own uid, so both run as root and drop privileges per command instead.
Everything Officer owns ends up belonging to that user and every pm2 process runs
as them, but the session you are left holding is root's.

Two things that fixes are invisible until they bite: group membership is fixed at
LOGIN, so the `docker` group just granted is not in the current session, and the
shell configuration was written into their home and is not loaded in root's. Both
present as "the machine is broken" rather than "log in again".

Printed by whichever half runs LAST. The first attempt put it at the end of both,
which says it twice on a full install — and the first time it is wrong, because
officer-setup is about to run and still needs the root session it tells you to
leave. install.sh is the only thing that knows whether anything follows, so it
sets OFFICER_SETUP_FOLLOWS and machine-setup stays quiet.

Also drops "Pre-flight complete. The remaining sections are not built yet." from
the end of officer-setup. All 11 sections exist; that line last made sense when 6
did.

Verified: bash -n on all three, the set -e behaviour of `$RUN_OFFICER && export`
under --machine-only, and the suppression across all five ways in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 06:05:57 +00:00
pastilhasandClaude Opus 5 cbfe376a42 accept the repo URL as an argument
bun setup -- --repo https://github.com/you/platform.git

The default is a private Gitea over SSH, which only authenticates on a machine
whose key it already knows — so a genuinely fresh server could not clone at all
without editing lib/repo.sh or knowing OFFICER_REPO existed.

Added to both entry points. install.sh exports it rather than forwarding an
argument it does not own; officer-setup.sh sets it before lib/repo.sh is sourced,
which reads `${OFFICER_REPO:-<default>}`, so an absent flag still defaults.

Two bugs found doing it, both pre-existing:

  - officer-setup.sh ALREADY had an arg parser, at the top, before the sources. My
    first attempt added a second one further down that was unreachable — every
    argument had already been consumed and `*)` would have exited 2 on --repo.
    Caught because `--help` printed the wrong usage.

  - both scripts re-execute through sudo passing `"$@"`, which the parse loop had
    already emptied with `shift`. So `officer-setup.sh --only build` run as a
    normal user silently became a FULL run the moment it escalated, and
    `install.sh --officer-only` re-ran the machine half. Nothing said so; the flag
    just stopped existing. ORIGINAL_ARGS is captured before the loop now.

`${ORIGINAL_ARGS[@]+"${ORIGINAL_ARGS[@]}"}` is the set -u safe form — expanding an
empty array is an error on bash before 4.4, and this runs on whatever the machine
came with.

Verified: bash -n on both, --help/--list/--repo/--repo=/unknown-option on both, the
set -e behaviour of the guarded export, and that args survive the shift loop.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 05:54:56 +00:00
pastilhasandClaude Opus 5 64f3de59fb CLAUDE.md caught up on how setup is run
It said `bun setup` runs officer-setup.sh and was "IN PROGRESS, sections 1-6 of 10".
It runs scripts/install.sh, and both halves are finished — machine-setup has 28
sections, officer-setup 11.

That line is probably why the orchestrator got doubted: the one document you would
check to find out how to install says the wrong entry point.

Added what install.sh actually is — an orchestrator that runs the two halves and
nothing else, either half runnable alone, both re-runnable, run it as yourself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 05:48:33 +00:00
pastilhasandClaude Opus 5 081920c61f drop fastfetch from machine-setup
It stopped a real install. The guard covered the wrong half: a PPA that fails to
ADD is caught and skipped, but one that adds cleanly while carrying no package for
the running codename gets past that and dies on `pkg_install_now fastfetch`.

It was also the only tool in the set with no source but a third-party PPA on Ubuntu
24.04 and older. A neofetch clone is not worth a branch in a script whose whole job
is to survive machines nobody has seen.

Removed from tools_default, the tool_command mapping and its installer. No shell
config invoked it, so nothing is left calling a missing binary.

software-properties-common stays in the core apt list for now, with a note: it
provides add-apt-repository, the fastfetch PPA was its only caller, and Docker
writes its own sources.list.d entry by hand — so it is now dead weight. Left as a
separate decision rather than folded into this one.

Verified: bash -n on all four scripts, and no live reference remains.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 05:46:59 +00:00
pastilhasandClaude Opus 5 fb0286e1a9 put rootless docker back, behind the developer gate
Uncommented at all three sites: the call and import in provisionOsAccount, the
~/.local/dockers bind-mount directory in confineUserTree, and the DOCKER_HOST block in
the member zshrc.

Not restored unconditionally, which is how it was before. It now sits behind the same
Developer check as the Postgres role — the gate that prompted disabling it in the first
place. So rolePermitsDatabase is renamed rolePermitsDevTools: it gates two things now
and a name saying "database" while deciding whether you get containers is the kind of
comment that goes stale silently.

~/.local/dockers is created for EVERY account rather than only Developers. It is two
install calls, and confineUserTree is the function that places the layout, not the one
that knows who is a Developer — so a member promoted later finds it already correct.

Postgres role work is untouched and still in place.

Verified: transpiles.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 12:49:32 +00:00
pastilhasandClaude Opus 5 d6f01862fe fix a stack overflow in the wallet copy button
Mine, from the clipboard sweep. format.ts exported copyToClipboard wrapping
navigator.clipboard; the sweep replaced the body call with copyToClipboard(value),
so the function called itself. CopyField.tsx is the caller, so every copy button in
the Wallet was an infinite recursion.

Removed the wrapper rather than repointing it — helpers/clipboard already does more
(execCommand fallback on an insecure origin) and CopyField imports it directly now.

Found by finally running tsgo, in the officerdev-test tree, which has node_modules.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 12:33:23 +00:00
pastilhasandClaude Opus 5 bf7e919593 only a Developer gets a postgres role
The gate rootless Docker was under, which I did not know about when the Postgres role
replaced it. It inherits the rule along with the purpose: a member not trusted to run
containers is not thereby trusted to run databases.

rolePermitsDatabase() is the single place that rule is written. Admin is deliberately
NOT included — administering the platform is not developing on it, and they are
separate roles precisely so they can be held separately. Say so if that is wrong.

provisionOsAccount now takes the role. Two call sites: create-user passes what the
owner picked, provision-linux-route reads it from the row — which makes that route
the way a member promoted to Developer gets the database role they did not qualify
for when their account was made.

The half that makes the gate real is in updateUserRoleHandler. Without it the rule
would decide what a Developer gets at creation and never look again, so demoting one
would leave their role, their databases and a working password in their ~/.zshenv —
a permission surviving its own revocation, with the UI then saying something untrue.

Revoke before recording, grant after: the drop runs BEFORE updateUser so a failure
aborts with the role unchanged and the whole thing retryable. Promotion runs after and
is non-fatal, like every other provisioning step.

Demotion keeps their data, same as deletion — databases are reassigned to the platform
role, not dropped — and logs where it went, so it does not look deleted.

KNOWN, not handled: a demoted member keeps a stale ~/.pgpass and ~/.zshenv naming a
role that no longer exists. Harmless (the connection just fails) but untidy, and it
means `cat ~/.zshenv` shows a password that no longer works.

Verified: transpiles, both call sites updated. Still no tsgo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 12:30:33 +00:00
pastilhasandClaude Opus 5 2ac58e2007 drop the postgres role when a member is decommissioned
It was written and never called — dropPostgresRole had zero call sites, so deleting a
member left their role and databases on the cluster.

That is the uid trap in a different id space, and worse. provisionPostgresRole ADOPTS
an existing role, so a role left behind is inherited whole, with its databases, by the
next member who gets the same username. useradd hands out the lowest free uid by
accident; the owner hands out usernames on purpose, so reuse is likelier here, not
less.

Rewritten to PRESERVE rather than destroy. The first version dropped the databases,
which is inconsistent with severMemberTree three files away — that chowns a member's
files to the service user rather than deleting them, and a database is the same kind
of thing. The owner removing an account has not necessarily asked to destroy the work
in it, and dropping is the one choice that cannot be walked back.

Needs the full idiom, per database, and both halves matter:

  ALTER DATABASE .. OWNER TO         REASSIGN OWNED does not move database ownership
  REASSIGN OWNED BY .. TO ..         moves tables, schemas, functions
  DROP OWNED BY ..                   removes what is left, which after a reassign is
                                     only the GRANTS — without it DROP ROLE still
                                     refuses, an ACL entry is a dependency too

Both statements act only on the database they are connected to, so it is a connection
per database rather than a loop over `db`.

Ordered after the Linux teardown (which can fail and abort, and must not do so after
something irreversible) and before deleteUser (the row is what remembers there is
anything to clean up).

Verified live: a member with a database, a table, a row and a schema. Naive DROP ROLE
refused with "2 objects in database carol_app". After the sequence: role gone, row
intact, table owned by postgres. Then recreated the same username and confirmed she is
REFUSED from the old database — the login trigger holds because ownership moved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 12:27:13 +00:00
pastilhasandClaude Opus 5 9f15de3448 put the postgres credentials somewhere findable
The password now lands in ~/.zshenv as PGHOST/PGPORT/PGUSER/PGPASSWORD, as well as in
~/.pgpass. Asked for on the grounds that it is an easier place to remember, which is a
real requirement — a credential you cannot find is one you will ask about every time.

.zshenv rather than the .zshrc that was asked for, for two reasons, neither about
secrecy:

  - zsh sources .zshrc for INTERACTIVE shells only. Verified: `zsh -c` prints an empty
    PGUSER when it is set there, and the right one from .zshenv. A script, a cron entry
    or an agent turn running psql would silently get nothing.
  - .zshrc is a shared template and seedShellConfig only updates it while it still
    matches byte-for-byte, so members keep their edits. A per-member password in it
    would strand every member on the template they were created with — a silent
    maintenance break rather than a tradeoff.

Both files are still written because they are not redundant: .pgpass is what libpq
reads with no shell involved, so it is the one that works for psycopg, a systemd unit
or a compiled binary. The rotate condition now covers both — either missing means we
cannot reconstruct it from the other, so we regenerate.

Also drops the `head -1 ~/.pgpass` parsing I had put in the zshrc template. It was a
hack, and the values are in the environment before that file is read now anyway.

Verified: zsh sourcing order and both shell types, transpiles. Still no tsgo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 12:24:05 +00:00
pastilhasandClaude Opus 5 3ca7f5f331 close the cross-member database leak with a login event trigger
The residue the last commit documented is gone. A database one member creates is now
refused to every other member at connection time, so the catalogue metadata never
becomes readable in the first place.

Both obvious routes are dead ends, measured rather than assumed: datacl is not
inherited from the template, and CREATE DATABASE fires no event trigger because it is
a global object. What works is a `login` event trigger (PG17+) installed in template1
— event triggers live in a per-database catalogue and CREATE DATABASE copies the
template's catalogues, so every member-created database carries it automatically. No
naming convention, no sweep, no window.

The first version put the function in `public` and a member defeated it in one
statement:

  DROP FUNCTION public.officer_owner_only() CASCADE;   -- takes the trigger with it

They could not drop or disable the trigger itself, but in PG15+ `public` is owned by
pg_database_owner — which resolves to THEM in their own database — and a schema owner
may drop objects in it they do not own. Both objects were owned by postgres and it
made no difference. Caught because I tried it rather than reasoned about it.

Moved into a platform-owned schema with PUBLIC revoked. Every route then refused:
DROP EVENT TRIGGER, ALTER .. DISABLE, DROP FUNCTION, DROP SCHEMA, ALTER SCHEMA ..
OWNER TO, CREATE OR REPLACE over the top, and PGOPTIONS=-c event_triggers=off (that
GUC is superuser-only). A superuser can still set it, which is the recovery path.

ensureTemplateIsolation opens its own short-lived connection because a connection
cannot change database and CREATE DATABASE requires no other session on the template
— a pooled connection to template1 would make every member's `createdb` fail.

Verified live, end to end: alice in her own, bob refused, alice refused from bob's,
postgres in, owner reads and writes normally, and both members refused CONNECT on
`officer`. Probe roles and databases dropped; template1 keeps the trigger, which is
the intended state.

Still not typechecked — node_modules is empty in this tree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 12:18:31 +00:00
pastilhasandClaude Opus 5 2273941e71 give each member a postgres role instead of a docker daemon
A Postgres login role named the same as their Linux account, with CREATEDB, plus a
~/.pgpass so psql never prompts. This is what replaces rootless Docker: the case it
was really there for was "let me run a database to develop against", and a container
per member answered it with a daemon, an image cache and a subuid range each.

Measured on postgres:18-alpine before writing any of it, because three things I
asserted turned out to be wrong:

  - a fresh LOGIN role CAN connect to `officer` (datacl NULL = PUBLIC has CONNECT),
    but CANNOT read any application table — privileges are owner-only, so
    has_table_privilege('users','UPDATE') is false. The capability model was never
    reachable from here.
  - the `trust` line in pg_hba does not cover host connections: Docker's NAT rewrites
    the source, so they fall through to scram-sha-256. Verified with a wrong password.
  - revoking from the ROLE does nothing. Privileges are additive and there is no DENY;
    only revoking from PUBLIC is a lock.

So ensureAppDatabaseClosed revokes CONNECT+TEMPORARY on the platform's own database
from PUBLIC, and it runs inside provisionPostgresRole rather than in the setup script
— an install set up before today, or restored from a dump, then still cannot end up
with a member who can connect to `officer`.

Password is generated per member, 40 chars, rejection-sampled over an alphanumeric
alphabet: CREATE ROLE is a utility statement and cannot take a bind parameter, so the
safety comes from the alphabet rather than from escaping. Not stored anywhere — it
lives in their 600 ~/.pgpass, the same posture as their SSH key, where we keep only
the public half. Only (re)set when .pgpass is missing, so a reprovision does not
rotate a credential they may have pasted into an app config.

KNOWN RESIDUE, not handled: a database one member creates is metadata-readable by
another. datacl is not inherited from the template (measured: closing template1 and
creating from it still produced NULL), and CREATE DATABASE fires no event trigger, so
nothing can close it at creation. A second member can read table and column NAMES from
the catalogue. They cannot read a row and cannot create anything. Closing it needs a
sweep or a pg_hba rule per member; both are decisions, not details.

Verified live against the running cluster: role creation, refusal on `officer`,
creating and using two databases, and the DROP ... WITH (FORCE) teardown. All probe
roles and databases dropped afterwards.

NOT verified: bunx tsgo, still — node_modules is empty in this tree. The drizzle
return shape was checked by reading PostgresJsQueryResultHKT (RowList<T[]>, extends
Array) rather than by running it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 12:14:02 +00:00
pastilhasandClaude Opus 5 41cdd1e63a stop creating the container bind-mount directory too
~/.local/dockers existed only so a rootless container's inner uid could traverse to a
bind source. No daemon, no need. Commented out with its reasoning intact in
confineUserTree.

.local itself stays — it is not Docker's. The claude installer targets ~/.local/bin,
and the comment above it records that directory being created root-owned and blocking
the install.

Nothing to change in deprovisionOsAccount: it never called os-user-docker.ts. Its
only Docker-shaped part is capturing /etc/subuid before userdel, which already treats
a missing entry as normal and is worth keeping for any rootless tooling.

Verified: transpiles. No test referenced composeDir.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 11:58:06 +00:00
pastilhasandClaude Opus 5 7b32f5bc2f don't provision rootless docker for new members
Commented out at the call site in provisionOsAccount, with the import. The code in
os-user-docker.ts stays and is now unreferenced — turning it back on is uncommenting
two blocks.

Only affects NEW accounts. Members provisioned before this keep their daemon, and
deprovisionOsAccount still tears one down, which is what those accounts need.

Verified: transpiles. tsgo has still not run in this tree (node_modules is empty).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 11:56:16 +00:00
pastilhasandClaude Opus 5 cc1eab7794 docs: a triage map of the documentation
42 documents, 13,000 lines, and no way to tell from a filename which describe the
system as it is and which record an afternoon in July. This sorts them: living,
stale, historical, and two clusters that want consolidating.

Says plainly how much was verified — mostly filenames, status lines and greps for
what changed today — so it reads as a starting point rather than a verdict.

Names the two obvious consolidations without performing them. Nine opencode
documents for one migration that has landed (verified: `opencode serve` is in the
sidecar, so the plan's "nothing here is implemented" is false), and three
mobile-dav documents that are one correspondence. Both need all of them read
first, which is not a 4am job.

Marks the historical ones as not-to-be-rewritten. claude-sidecar-isolation.md
records the officer-claude to officer-agent rename that preceded tonight's rename
to officer-claude-code; editing it to match today's code would destroy the
reasoning it exists to hold.

And notes what most of them share: they were written when the estate was twenty
processes and everything was simply present. A core install is six. The fix is
usually one line — say whether the thing is core or a plugin — not a rewrite.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 05:26:48 +00:00
pastilhasandClaude Opus 5 8826c2847e docs: working-on-officer catches up to the code
It described four capability kinds and said terminal, chat and files can never be
granted. There are five, and those three moved to `confined` on 2026-08-11 — the
kernel enforces the boundary because the account has its own Linux user, and a
grant means nothing without one.

The layout diagram was missing dockers/ and secrets/, and implied the paths are
configured. They are derived from the working directory, which is why the pm2 cwd
pin and assertInstallLayout exist.

Adds what is switched off as of tonight: six core processes, every plugin router
commented out beside its capability claim, the ecosystem files now generated, and
.env down to three values with the keys in the secret store.

First of a documentation sweep. 42 docs; this one first because it is the
operational guide somebody actually reaches for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 05:26:07 +00:00
pastilhasandClaude Opus 5 236a3a5481 docs: first container test pass, and what it found
Ubuntu 24.04, Debian 12, Arch and Fedora 41. OS and package-manager detection
correct on all four; --help works unprivileged; and the install report is written
end to end in a container that had never seen this code — task 1's mechanism
confirmed off the machine it was written on.

Three real findings.

--only does not isolate a step. Running --only "Core utils" still created a user
account, because ask_username and the account creation sit in the preamble above
the step framework, so everything before the first `step` runs every time. It is
defensible and it is not what the flag appears to promise.

.setup-answers travels with a copy of the tree. Correctly gitignored and 0600,
but it lives inside the repository directory, so `cp -r` carries it — a container
that had never run setup came up already knowing the username and created that
account. Nothing secret in it; it is a surprise, which in an installer is the
expensive kind.

adduser leaks its own interactive prompt ("Try again? [y/N]") on the
account-creation path. Harmless here because the run had already stopped, but a
hang on a real unattended install.

Also records what containers cannot reach: no init means systemd, netplan, ufw
and the sshd drop-ins are only verifiable as "wrote the right file"; Docker and
Postgres are untested; macOS is unreachable entirely and everything about it is
reasoned rather than executed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 04:00:31 +00:00
pastilhasandClaude Opus 5 d7d64cd6d6 docs: the install-variant tree, for tomorrow's decision
Enumerates the forty-seven prompts the two scripts actually ask and sorts them
into branches, consents and values — the distinction that decides what a
generated leaf can remove. Four real branches (OS, role, tailnet state, which
half), fourteen consents that let a leaf omit a section entirely, and a set of
values that must stay prompts because baking them in would mean publishing
somebody's hostname.

Names the two things that need deciding rather than deciding them:

Whether a leaf strips dead code or sets constants and calls the base. They are
different artifacts and the plan rests on which one is meant — the first is what
makes it auditable by being short, the second is what keeps it maintainable.

And the combinatorics: 4 OS x 3 roles x 3 tailnet states is 36 leaves before
consents, so the tree cannot be the full product. Publishing a few opinionated
leaves keeps the static-file-anyone-can-diff property; generating on demand does
not, which is the property per-leaf scripts existed for.

Also notes that --unattended and a generated leaf are the same mechanism seen
twice, and that install_config's existing behaviour — keep the user's file when
there is no tty — is the conservatism every unattended answer needs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 03:58:32 +00:00
pastilhasandClaude Opus 5 cd209483e3 fix the clipboard over http, and audit the rest
navigator.clipboard is secure-context only, like crypto.randomUUID before it —
over plain http on a tailnet address the object does not exist. Twenty call
sites across eighteen files, in three states that all looked fine in review:
bare calls that threw and killed the handler, optional-chained calls that
silently did nothing, and one carrying the comment "Officer is always behind
HTTPS", which it is not.

The optional-chained ones are the worst of the three: a copy button that reports
success and copies nothing is indistinguishable from a working one until someone
pastes.

helpers/clipboard.ts falls back to document.execCommand('copy') over an
off-screen textarea — deprecated, and it works on any origin because it predates
the secure-context rule. Off-screen rather than hidden, because display:none and
visibility:hidden elements cannot be selected and the copy fails silently.

Reading the clipboard has no equivalent: execCommand('paste') was never permitted
from script. The file browser's paste-a-file path now checks canReadClipboard()
and explains itself instead of throwing.

docs/http-secure-context-audit.md is the full sweep the owner asked for: what was
fixed, what cannot be, and what was checked and found clear. crypto.subtle is
used nowhere in the frontend, which was the one worth confirming since it has no
cheap fallback. Notification's six matches are type names, not the API.
geolocation and navigator.share are already guarded. getUserMedia is in four
files and is being removed — but QrTransfer uses it for the CAMERA, not a
microphone, so "remove audio" does not cover it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 03:56:57 +00:00
pastilhasandClaude Opus 5 77f1284925 install report: first cut, generated by the helpers
Every run writes a timestamped install-report.md recording what was installed,
changed, kept, skipped, started and run as root. Written for an adversarial read:
the person who just ran a setup script off the internet hands it to an agent of
their choosing and asks whether it did anything it should not have.

Recorded by the HELPERS rather than by the sections. pkg_install and
install_config report themselves, so anything installed or written through them
appears whether or not a section author remembered — a section that has to
remember is a section that will forget, and an incomplete report is worse than
none because it reads as a full account.

"Kept" is recorded as carefully as "changed". Leaving somebody's .zshrc alone is
the claim a reviewer most wants substantiated, and it is invisible unless stated.

Secrets are redacted at the moment of recording rather than filtered at render,
so a credential never sits in memory formatted for printing. Verified against a
POSTGRES_URL and an api_key/password pair.

REPORT_FILE is passed through the sudo re-exec. It was not, first time, and the
report silently vanished — the third variable this evening lost to env_reset.

Unfinished on purpose, paused mid-task at the owner's request: machine-setup's 26
sections still only report through the two shared helpers, so the sections that
change system state directly — systemd units, netplan, ufw, sshd drop-ins — are
not yet recorded. That is the half a reviewer would care most about.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 03:33:48 +00:00
pastilhasandClaude Opus 5 74b4c7908a fix the crash over http: crypto.randomUUID is secure-context only
Chat took the whole page down at the end of every turn with
`TypeError: crypto.randomUUID is not a function`.

`crypto.randomUUID()` is SECURE-CONTEXT ONLY — over plain http on anything that
is not localhost it is not defined at all. Officer is reached at
http://officer-dev:9000, which is neither, so all eighteen call sites in the
frontend were throwing. The stack shows why it was fatal rather than merely
broken: it was called inside a `useState` initialiser, so the throw happened
during render and unmounted the tree. The assistant message that has no id yet
is created at the end of a turn, which is exactly when it fired.

No TLS needed. `crypto.getRandomValues()` carries no such restriction — it is on
`Crypto`, not `SubtleCrypto`, and works in an insecure context. helpers/random-id
uses randomUUID when it exists and otherwise assembles a v4 from the same CSPRNG:
same 122 bits, same version and variant bits. Verified both paths produce a UUID
matching the v4 pattern, including with randomUUID deleted.

`crypto.subtle` is not used anywhere in the frontend, so randomUUID was the whole
of the problem. Audio recording is a different matter — getUserMedia genuinely
requires a secure context and cannot be polyfilled.

Nine files, eighteen call sites. The vendored hls.mjs is left alone.

Two of my own mistakes on the way, both caught by parsing rather than by reading:
the rewrite added an import of the helper TO the helper, and inserted another one
inside a multi-line import block — the same trap as the officerdb move earlier
tonight.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 03:23:31 +00:00
pastilhasandClaude Opus 5 f1cfc0042f rename officer-agent to officer-claude-code
The old name said nothing about what the process runs, and it sits directly
beside officer-anthropic-proxy — a different process doing a different job — so
"the agent" was ambiguous exactly where it mattered. CLAUDE.md already had to
spend a paragraph insisting the two are not the same thing. It spawns `claude`;
the name says so now.

Only two references were functional: the generator's CORE_PROCESSES and the CORE
list in catalogue.test.ts. Everything else was prose or comments.

Left alone deliberately: `x-officer-agent-token`. It looks like the same string
and is not — it is the agent-handoff HTTP header, naming a per-panel bearer
token, unrelated to any pm2 process. Renaming it would have changed a wire
protocol to tidy a label.

Historical docs keep the old name. claude-sidecar-isolation.md and
open-threads-after-per-user-claude.md are dated investigations that record the
PREVIOUS rename, from officer-claude to officer-agent, and rewriting them would
make that history unreadable. CLAUDE.md notes the change instead, where somebody
reading those will be looking.

Also worth recording, from the owner: merging this with officer-anthropic-proxy
into one sidecar was investigated tonight and rejected. They stay separate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 03:10:39 +00:00
pastilhasandClaude Opus 5 88c9e96895 recover earlier answers on resume
A skipped section leaves its variables unset and later sections read them, so on
a resume — which skips every section before the one that stopped — Build
announced "PUBLIC_URL <not set — run the Environment section first>" on a machine
whose .env had been written twenty minutes earlier.

Three variables cross a section boundary: ENV_PORT and ENV_PUBLIC_URL from
Environment, POSTGRES_URL from Database. They are read back once near the top,
from the file that already holds the answers, rather than per-section — the next
variable to cross would otherwise have to remember to do it again.

Only fills what is empty, so a value passed on the command line still wins and a
section that actually runs still overwrites it.

Build had its own late read-back that made the generation work while the screen
said it would not. Removed, now that the value is there before anything prints.

Verified against the real install at /home/pastilhas/officerdev-test: --only Build
now reports the URL that run chose.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 03:08:18 +00:00
pastilhasandClaude Opus 5 e5fe966308 count the schema tables instead of printing "?"
The Schema section read ${SCHEMA_TABLES:-?} and nothing ever assigned it, so it
announced "? tables" — which reads as "the count could not be determined" rather
than "nobody set this". schema_table_count existed in lib/build.sh and was never
called.

Counted from schema.ts rather than hardcoded, so the number stays true when a
plugin line is uncommented. Reports 21 against the current tree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 03:06:04 +00:00
pastilhasandClaude Opus 5 60ab531294 use the short tailnet name, not the FQDN
http://officer-dev:9000 rather than http://officer-dev.ts.pastilhas.dev:9000.
All three forms resolve inside the tailnet — short name, FQDN, raw 100.x — and
the short one is what anybody actually types. PUBLIC_URL is read by people too:
gen:index bakes it into the page's OpenGraph tags.

It depends on the tailnet's search domain, which every Tailscale client sets when
MagicDNS is on. A device that has lost it resolves the FQDN instead, and the
answer there is to type the longer one rather than to default everybody to it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 03:05:04 +00:00
pastilhasandClaude Opus 5 f33e7474b0 default PUBLIC_URL to the tailnet address, not localhost
localhost is wrong on a machine with a tailnet, and quietly so: it works from the
machine itself and nowhere else, so the mistake surfaces on the first phone
rather than during setup. And PUBLIC_URL is not decoration — gen:index bakes it
into the page's OpenGraph tags, the task API hands it to scripts as
OFFICER_API_HOST, and the CalDAV profile builder refuses without it.

The tailnet is where Officer is actually reached, and it is the perimeter the
whole security model rests on now that origin checking is gone. Its address is
the honest default.

Prefers the MagicDNS name over the raw 100.x address — both work, but the name
survives a node being re-registered and is something a person can type. Falls
back to localhost with no tailnet, which is right rather than merely tolerable:
a machine with no private network has no better address to guess.

Suggests http://officer-dev.ts.pastilhas.dev:9000 on this machine.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 03:03:45 +00:00
pastilhasandClaude Opus 5 bedc420d4d clone over SSH while the repository is private
An HTTPS clone of a private repo prompts for a username, and under sudo with no
interactive terminal that hangs or dies with "could not read Username" — which is
what gitea.pastilhas.dev does right now.

ssh://git@gitea.pastilhas.dev:2222/officerdev/platform.git instead, temporarily.
Back to HTTPS when it is public; nothing else in the script cares which.

Tested the path the script actually takes, not just the URL: the clone runs as
the OWNER rather than root, and sudo drops SSH_AUTH_SOCK, so there is no agent to
answer a passphrase. `sudo -u pastilhas env -u SSH_AUTH_SOCK git ls-remote`
returns HEAD, so the key works unaided on this machine. A passphrase-protected
key that relies on an agent would not.

Still overridable with OFFICER_REPO.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 02:56:55 +00:00
pastilhasandClaude Opus 5 9b56e04b5e point the clone at gitea.pastilhas.dev
gitea.officer.dev is not serving yet. Overridable with OFFICER_REPO, as it always
was, so a fork or a mirror needs no edit here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 02:55:42 +00:00
pastilhasandClaude Opus 5 ea87ffed04 ask for privileges, do not demand them
Run any of the three as yourself. On Linux they now ask through sudo and
re-execute, rather than refusing until you type it. Typing `sudo` still works and
changes nothing — it just stops being the price of starting.

This also fixes a trap that had nothing to do with taste. `sudo` strips the
environment by default (`env_reset`), so `OFFICER_ROOT=/somewhere sudo ./install.sh`
silently loses the variable and installs to the default path instead. The
re-exec passes OFFICER_ROOT, SETUP_USERNAME and MACHINE_ROLE to sudo BY NAME
rather than relying on -E, which env_reset ignores. This project has already lost
a variable to that once — see the DATA_PATH commit.

The invoking account is recovered the way it always was: sudo sets SUDO_USER,
which lib/base.sh already reads, including the check for a SUDO_USER that is
itself uid 0 on providers whose default account is root under an ordinary name.

macOS never escalates, in any of the three. Homebrew refuses to run as root, the
account running the script IS the owner, and the sections that needed root are
the ones the macOS path skips.

--help and argument errors still work with no privileges at all, since arguments
are parsed before any of this.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 02:54:10 +00:00
pastilhasandClaude Opus 5 3cca07187e scripts/install.sh — one command for both halves
`bun setup` runs it. Machine setup first, then officer setup, stopping if the
first does not finish rather than running the second against a machine that is
not ready.

They stay two scripts because they answer two different questions and are worth
running apart — a machine you already trust needs only the second, one you are
rebuilding needs only the first. --machine-only and --officer-only say so
directly, and both halves remain runnable by path.

Privileges are checked here, before anything is done, because the two systems
want opposite things: Linux needs root for apt, systemd, useradd, netplan and ufw
and for creating directories owned by the service account; macOS must NOT be
root, since Homebrew refuses to run as one. Each script already enforces its own
rule, so this is only about failing early instead of halfway.

officer-setup.sh gained the same OS-aware check. It required root unconditionally,
which on macOS would have failed immediately after machine-setup — which must run
as the user — and for no reason: there the account running it IS the owner, so
there is nothing to chown and nothing to drop privileges to.

Arguments are parsed before privileges, so --help works without sudo and an
unknown option is rejected before anybody is asked for a password. It did not,
first time round.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 02:49:44 +00:00
pastilhasandClaude Opus 5 b5aa2e0387 keep the shell templates together in scripts/setup
starship.toml, tmux.conf and zshrc side by side. tmux.conf and zshrc moved up out
of machine-setup/.

starship.toml could not have moved down to join them: the PLATFORM reads it, at
src/servers/os-user-shell.ts:34, to deploy to every member's Linux account. That
is a runtime path rather than an import, so moving it would have broken member
provisioning silently — no build error, members simply get no starship config.
So the templates collect where the shared one already had to be.

Left as a marker for tomorrow rather than resolved: there are now TWO zshrc
templates, this one for the owner and shell-skel/zshrc for members, while
starship.toml is deliberately one file for both. Either the owner needs different
shell config from a member or they should be the same file. tmux.conf has the
same question waiting, since it is going into provisioning too. Noted in the
zshrc header where whoever picks it up will be looking.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 02:47:08 +00:00
pastilhasandClaude Opus 5 0da15d78a1 add an empty zshrc template for the owner
Somewhere to put what the owner actually wants, to be filled in and wired up
tomorrow. Not referenced by the Shell section yet.

The header records the decision that has to be made when it is: the section does
not install a .zshrc today, it appends four marker-wrapped blocks — starship,
agent, aliases, editor — through append_once. A template that is installed AND
appended to ends up with the same lines twice, so those blocks either move into
this file or stay out of it, not both.

zshrc, no leading dot: a template in a repository, not a dotfile in a home
directory, matching tmux.conf beside it and src/servers/shell-skel/zshrc which
has been spelled that way all along.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 02:45:08 +00:00
pastilhasandClaude Opus 5 e2faad40a3 rename the tmux template to tmux.conf, no leading dot
It is a template in the repository, not a dotfile in a home directory — and the
destination it is increasingly installed to, ~/.config/tmux/tmux.conf, has no dot
either. Naming the source after a path it may not be written to is how the wrong
file gets read.

The two remaining dotted references are correct and stay: they name the
DESTINATION ~/.tmux.conf, which does have a dot when that is where tmux looks.

.setup-answers and .setup-progress keep theirs too. They are runtime state in a
directory, not templates.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 02:44:07 +00:00
pastilhasandClaude Opus 5 61a3ae720f install the tmux config where tmux actually reads it
tmux 3.1 added an XDG location and it takes PRECEDENCE over ~/.tmux.conf.
Verified on 3.4 here by writing a different marker into each and asking tmux
which one it ended up with:

  both present       -> ~/.config/tmux/tmux.conf
  only ~/.tmux.conf  -> ~/.tmux.conf
  only the XDG one   -> the XDG one

So the Shell section writing ~/.tmux.conf on a machine that already has the XDG
file produced a file tmux will never read, and reported "tmux config installed"
having changed nothing anybody could observe. That is the worst shape a config
step can have: it looks done.

tmux_config_target now picks the path tmux will actually load — the existing XDG
file if there is one, otherwise ~/.tmux.conf, which is still what every guide
names and what a machine with neither should get. When both exist the section
says so out loud before targeting the winner, because "your other file wins" is
not something anyone infers from a success message.

Also removed an untracked duplicate at scripts/setup/.tmux.conf. The one the
script installs is scripts/setup/machine-setup/.tmux.conf — SCRIPT_DIR is the
machine-setup directory — and two identical copies with only one of them read is
the drift this whole evening has been about.

Nothing to change about the config itself: the tracked copy is already byte-for-
byte the owner's own ~/.tmux.conf.

Not yet wired into per-user provisioning. src/servers/shell-skel/ seeds a zshrc
for a member and has no tmux config beside it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 02:43:08 +00:00
pastilhasandClaude Opus 5 085afe7604 wait for Postgres instead of failing startup work once
The failure here was not a crash — it was the opposite, and that is why it would
never have been noticed.

server.tsx fired initQueue() and cleanupOnStartup() as bare promises with a
.catch() that logged. postgres-js connects lazily, so nothing fails at import;
the first query does. If Postgres is a few seconds behind — exactly what a reboot
looks like, with pm2's resurrect racing Docker starting the container — both log
one line during boot and do nothing else.

cleanupOnStartup is the one that matters. It marks jobs interrupted by the
previous shutdown and promotes the queued backlog, so failing it once leaves
those jobs marked running forever: nothing retries, nothing complains again, and
the only thing that would have corrected them has already run.

officerdb now exports waitForDatabase(timeoutMs = 60s): polls `select 1`, logs
once while waiting, resolves true or false rather than throwing. Bounded on
purpose — an unbounded wait holds a process open with no way to tell starting
from hung, and the caller decides what giving up means.

Deliberately NOT awaited before serve(). The listener is already up by that point
and holding it closed would turn a database thirty seconds late into a reverse
proxy answering connection-refused instead of a page. Requests needing the
database fail honestly in the meantime.

The rest of the estate was already fine, which is worth recording so nobody
"fixes" it again: postgres() opens no socket at construction, officer-agent's
resolveOwner is an unbounded 5s retry loop written after this exact failure cost
a session, and opencode, pty, headscale and the anthropic proxy touch no database
at boot at all.

officer-setup's Services section also waits for pg_isready before starting pm2.
Not because starting early breaks anything, but because Verify would then report
a failure that is really a race — and a red line that is usually noise is a red
line people stop reading.

Verified waitForDatabase against a dead port: logged once, returned false after
the timeout, did not throw.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 02:38:36 +00:00
pastilhasandClaude Opus 5 62cbf510cb no ecosystem files in git; officer-setup generates one
Deleted all four — ecosystem.config.cjs, .light., .mac.light. and the
.profile. they derived from. The repository now contains no ecosystem file at
all, and .gitignore keeps it that way.

officer-setup writes one at the end, describing exactly the six processes a core
install runs: officer, officer-anthropic-proxy, officer-agent, officer-opencode,
officer-pty and officer-headscale. No profiles, no derivation, no plugins.

The four existed because a profile has to subtract from something, so the full
list had to name every plugin's process whether or not anybody installed it —
and a test then had to assert the two files still agreed. Generating one file
removes the subtraction, the second list and the test that policed them.

It is .cjs, not the .js PM2's docs use, and that is not a preference:
package.json declares "type": "module", so a .js file here is ESM and
`module.exports` throws. PM2 require()s the config.

Sections 10 (Services) and 11 (Verify) are built on top of it — write,
startOrRestart, save, optional boot hook, then check every process is online with
a sane restart count AND that the API actually answers on PORT. A process can be
`online` and serving nothing, so the port is asked directly rather than inferred.
That completes all eleven sections.

catalogue.test.ts required both deleted files at import, so it could not even
load. Its central assertion — "the store offers exactly what light leaves out" —
has no meaning without a full list to subtract from, which is the point of the
change. Replaced by two weaker but real checks: the store must not offer a core
process, and every process it names must have a sidecar directory to run. The
second catches the same typo the old one did without needing a manifest of
everything; verified it holds for all 15 catalogue entries.

app-store/pm2.ts starts a sidecar with `--only` against this file, which now
holds core alone — so it can stop a plugin but cannot start one that was never
written in. Marked `[open]` there rather than left to be discovered: appending a
plugin's entry is the plugin system's job.

Generated one in a scratch directory and required it with node: six apps, correct
cwd on each, valid CommonJS.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 02:35:12 +00:00
pastilhasandClaude Opus 5 b075f1f882 macOS is a dev machine, and machine-setup now treats it as one
Officer on a Mac is a dev helper on a laptop somebody sits at. It is never the
homelab or VPS case, so the role is not asked for there — it is `dev`, and every
section that exists to make a machine a good server is skipped.

Seventeen of twenty-six sections skip, listed once in MACOS_SKIP in lib/base.sh
with a reason each, rather than an `if macos` threaded through each section. Most
would simply fail — no systemd, no ufw, no netplan, no useradd, no
/etc/ssh/sshd_config.d — but a few would SUCCEED and be wrong, which is worse:
stopping a laptop from sleeping, or freezing the address of a machine that moves
between networks daily.

Nine run: System update, Core utils, Tailscale, Command-line tools, Git, Docker,
Neovim, JavaScript runtimes, Agent CLIs.

The blocker was root. Linux needs it for nearly everything; Homebrew REFUSES to
run as root and says so, so the whole script under sudo would have failed at the
first brew install having already taken a password. It is now required on Linux
and refused on macOS, which works precisely because the macOS path skips
everything that needed it.

Docker is checked, not installed. Docker Desktop is a GUI app that wants opening,
permissions and a running window — not a shell script's business — and colima and
lima both cost an evening the first time something does not resolve. So the step
reports whether the daemon answers and points at the download otherwise. The
group-vs-rootless choice below it is Linux only: Desktop runs containers in a VM
owned by whoever is logged in, so there is no group to join.

Added the Xcode command line tools as a macOS-only step, before anything that
builds. node-pty ships no prebuilt binary on any platform and always falls
through to node-gyp, so `bun install` cannot finish without a compiler — and it
fails deep in a dependency tree naming neither Xcode nor node-pty. `xcode-select
--install` opens a dialogue and returns immediately, so the step says to come
back rather than pretending to have waited.

Tailscale takes the cask, not install.sh — that script is a Linux package-manager
wrapper. The cask ships a usable CLI; the Mac App Store build is sandboxed and
does not.

Not run on a Mac. There isn't one here, so this is read from the code and from
what each tool documents, not observed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 02:29:56 +00:00
pastilhasandClaude Opus 5 7280d13b93 group the barrel by core and plugin
Same 23 features, same order within each group, split by a blank line and a
heading. It mirrors schema.ts, where the plugin tables are commented out.

The difference between the two files is written down rather than left to be
inferred: these lines are NOT commented, because doing so would break nothing at
runtime — hono.ts mounts no plugin router, so none of it is reached — but tsgo
checks every file under src/ whether it runs or not, and the plugin sidecars
still import these symbols. They stay until each plugin is extracted.

No export changed. Verified: same set of re-exported features before and after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 02:20:10 +00:00
pastilhasandClaude Opus 5 fbb6d6c78c the agent sidecar crashed on boot without the email plugin
user-instance.ts read `getEmailAccounts` at module level, unguarded, in a
top-level await. Commenting the email schema out earlier tonight means
`email_accounts` does not exist on a core install, so that query throws, the
rejection escapes, and officer-agent exits — into the PM2 restart loop, taking
chat with it. Chat is core; the agent sidecar is what spawns `claude`.

The same file's header documents this exact failure being fixed once already, for
`resolveOwner`: "A query that THROWS — Postgres restarting, or not up yet —
escaped this function, rejected the top-level await, and exited the process into
exactly the PM2 restart loop the comment below says it exists to avoid." That cost
a session on 2026-08-10. This line has the identical shape and was never covered,
because until tonight the table always existed.

Guarded now: no accounts, a warning, and the email_db tool sits idle. An agent
must start without email — it is one tool, not a prerequisite for chat.

Checked the rest while there: the only other top-level awaits in the core
processes are resolveOwner (already hardened), sign (now backed by the secret
store, which creates on demand) and assertSecretsClosed (throws on purpose).

The three vault calls in core auth — signout, revoke, panic — are
`clearVaultTokens(...).catch(() => {})`, fire-and-forget, so a missing table is
swallowed. They are fine as they stand.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 02:16:34 +00:00
pastilhasandClaude Opus 5 3cb39662b5 desktop is a plugin too
Its sidecar, officer-vnc, was already excluded from the light profile, so calling
it core was only the capability kind saying `execution` — which is about who may
reach it, not whether a light install runs it.

Unmounted the same way as the other twelve: `/desktop`, its capability's api and
ws claims, the `desktop` websocket handler and its upgrade route. Implementation
untouched.

Also reverts a mistake from the previous commit. I had commented entries out of
WSData's `provider` union and left `upgradeWs`'s parameter type listing them,
which would have been a type error the moment either was used — and one I cannot
see here, since node_modules is empty and tsgo does not run. Those unions describe
possible values rather than what is served, and neither is a registration. Only
registrations are commented now, which is what was asked for in the first place.

Totality simulated again: 33 live mounts, 5 websockets, zero problems.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 02:14:52 +00:00
pastilhasandClaude Opus 5 ffca309a77 switch off every plugin router, pending extraction
Twelve capabilities unmounted: gitea, music, photos, jellyfin, memos, calendar,
email, notify, transmission, soulseek, invoices, wallet. Mounts commented in
place, implementation untouched on disk, same as vault and the browser relay.

Each needed its capability's `api` claim commented in the same change.
assertCapabilityTotality check 2 refuses to boot on a capability claiming a
prefix nothing mounts, so unmounting alone would have stopped the server
starting — the opposite of what happened with vault, which is exempt.

music also claims two websockets. Its `ws: ['cliamp', 'cliamp-audio']` claim, the
two handlers in server.tsx and the provider union entries all had to move
together: check 3 fails on a served socket nothing claims, check 4 on a claimed
socket nothing serves.

calendar was the awkward one and the mount list was not where it lived. Four of
its six doors are not in the routes table:

  honoServer.route('/dav', davSyncRouter)     the sync door, top-level, not /api
  honoServer.all('/.well-known/caldav')       RFC 6764 autodiscovery
  honoServer.all('/.well-known/carddav')      the same for contacts
  four routes in server.tsx                   /dav, /dav/*, and both well-knowns

and the two that ARE in the table carry trailing comments, which is why the first
pass silently missed them and left /api/caldav and /api/dav mounted with their
claim gone — exactly the boot failure this commit is about.

/vpn deliberately stays. It reads as a plugin (kind 'app') but it is OffTail
enrollment forwarding to officer-headscale, which is core because the tailnet is
the perimeter.

Verified by simulating assertCapabilityTotality against the post-change files
rather than trusting the edits: parse hono's live mounts, the registry's live
claims, server.tsx's live ws providers and totality's two exempt lists, then run
all four checks. It reported the two stale caldav mounts, which is how they were
found. Zero problems now, 34 live mounts, all core.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 02:13:16 +00:00
pastilhasandClaude Opus 5 1eecc400a7 switch off Vaultwarden's routers, pending extraction into a plugin
Same treatment as the browser relay: mounts commented, code left on disk. Its
tables were already commented out of the schema earlier tonight, which is what
made this necessary — /api/vault was mounted against tables db:push no longer
creates, so a fresh core install shipped an endpoint that could only fail with a
Postgres "relation does not exist".

Vaultwarden is not one mount. Eight places had to go, and grepping for `vault`
found them only because several are not named after a router:

  hono.ts   /api/vault                      the authenticated reverse-proxy
            /vaultwarden                    the unauthenticated one for the browser extension
            VAULT_ONLY_PREFIXES loop        /identity, /notifications, /icons, /events
            the isBitwardenClient diverter  an /api/* middleware that hands Bitwarden
                                            clients to the vault router before anything else sees them
            ./api/vault/sidecar-server      a SIDE-EFFECT import capturing the sidecar's port
            UNPROTECTED_API_PREFIXES        the '/vault' entry
  server.tsx  the 'vault' ws provider, its handler, and the notifications upgrade route

The side-effect import is the one worth naming: it registers a sidecar listener
and appears in no route table, so nothing about unmounting the routers would have
stopped it running.

No capability registry change, unlike browser and task-logs. Vaultwarden is
exempt from totality on both halves — EXEMPT_API_PREFIXES has '/vault'
("Bitwarden protocol clients authenticate to Vaultwarden, not to Officer") and
EXEMPT_WS_PROVIDERS has 'vault'. So nothing claims it and nothing breaks by
unmounting it. I said the opposite before checking; the check is what settled it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 02:05:54 +00:00
pastilhasandClaude Opus 5 595dd082a7 delete Task Logs
A complete read path over a table nothing could write to. task-logger.ts exported
createTaskLog, appendToLog and finalizeLog, and none of the three was called
anywhere in the tree — so `task_logs` could never gain a row, and the screen was
permanently empty for everyone.

The read half was fully wired: mounted router, capability claim, dock icon, two
routes and a page-title rule. That is why it looked alive.

Gone, in the order it was reached:

  Screens/Dashboard/TaskLogs/       the screen
  App.tsx                           /task-logs and /task-logs/:id
  Dashboard/index.tsx               the export
  Layout/Dock.tsx                   the 'Logs' icon, and ScrollText with it
  state/usePageTitle.ts             the title rule
  api/task-logs/task-logs.ts        the router, and its mount in hono.ts
  api/task-logger.ts                101 lines of orphaned writer
  officer_db/src/operations/        the directory
  officer_db/src/schema.ts          the export line
  officer_db/src/types.ts           TaskLogSelect / TaskLogInsert

capabilities/registry.ts loses '/task-logs' from the `tasks` capability's `api`
AND `routes`. The api half is not optional: assertCapabilityTotality check 2
refuses to boot on a capability claiming a prefix nothing mounts, so unmounting
the router while leaving the claim would have stopped the server starting.

db:push now creates 21 tables, down from 43 at the start of the evening.

officer_db/src/operations was one of the two lopsided directories the
schema/queries merge exposed. integrations/ is the remaining one, and it is
legitimate — it spans server and user-data.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 01:56:07 +00:00
pastilhasandClaude Opus 5 f9cd0a798a drop two dead tables: queue_jobs and terminal_containers
Neither had a reader or a writer anywhere in the tree. db:push created them on
every fresh install and nothing ever touched them.

queue_jobs is from when the background job engine kept its state in Postgres; it
works on files now (src/servers/queue/storage.ts). terminal_containers held a
docker id and port per user, from the architecture where every account ran in its
own container — gone, as data-path.ts already records.

Their types went with them: QueueJobSelect/Insert and TerminalContainerSelect/
Insert were unreferenced too. db:push now creates 22 tables.

operations/schema.ts keeps task_logs and a note about what left and why, along
with the thing still wrong there: it has no queries.ts, because task_logs is
reached as `schema.taskLogs` from src/servers/api/task-logger.ts, past this
package's boundary.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 01:49:20 +00:00
pastilhasandClaude Opus 5 288b4bf683 each feature declares its own exports; the barrel is one line per feature
src/index.ts is 47 lines and reads as a list: `export * from './agent-panels';`
and 22 more, under `db` and `schema`. It was 297 lines of hand-written named
exports.

What a feature exports now lives in <feature>/index.ts, beside the schema and
queries it describes. Adding a query function is one file in one directory rather
than that file plus a list three levels up that nothing enforces — a function
missing from that list was invisible to all 107 consumers while existing and
compiling perfectly.

The old file had drifted in the ways a hand-maintained list does: twelve features
were listed twice because values and types were separate statements repeating the
path, soulseek three times, two features used an inline `type` specifier instead,
and `db` and `schema` — the package's most fundamental exports — sat at line 270
with notify, types and app-store appended after them.

The surface is byte-for-byte the same set. Checked rather than asserted: 247
exported names before, 247 after, no missing and no extra. The per-feature index
files carry the same named lists the barrel did, so `export *` widens nothing.

`operations` is deliberately not in the list, and the root file says why: it has a
schema and no queries, its task_logs is reached as `schema.taskLogs` from
src/servers past this package's boundary, and its other two tables are read by
nothing at all.

Verified: every file in the package parses, every relative import resolves to a
real file, and all 107 consumers still parse. Not typechecked — empty
node_modules, frozen installs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 01:45:31 +00:00
pastilhasandClaude Opus 5 68f2c55ecf one directory per feature: schema.ts and queries.ts together
src/databases/officer_db/src/<feature>/{schema.ts,queries.ts}, replacing the
parallel schema/ and queries/ trees. 24 feature directories, 46 files moved with
git mv so history follows.

The parallel trees had drifted, which is what the restructure is really fixing:

  four features were named differently on each side — app-store/sidecar-installs,
  email/email-accounts, server/server-config

  operations had a schema and NO query file: its task_logs is reached directly
  from src/servers/api/task-logger.ts, bypassing this package's own boundary

  integrations had queries and NO schema, because it spans two features'
  tables — server_integrations and user_integrations

Both lopsided cases survive as directories holding one file, which states the
problem instead of hiding it across two trees.

Nothing outside the package changed how it imports. `officerdb`, `officerdb/types`
and `officerdb/db` resolve exactly as before; index.ts absorbed the path changes.
Added `"./*": "./src/*"` so the new layout is reachable — `officerdb/soulseek/schema`
— which one script needed, because soulseek is a plugin and therefore commented
out of the aggregator.

schema/index.ts became src/schema.ts, keeping the core/plugin split from earlier
tonight. drizzle.config.ts and the package's "./schema" export follow it.

Verified rather than assumed: all 52 files in the package parse, every relative
import resolves against the new layout (checked by walking each specifier to a
real file, since parsing does not check paths), and everything in the tree
importing officerdb still parses. Not typechecked — empty node_modules, frozen
installs.

One rewrite bug worth recording: the rule mapping a query module's sibling import
also matched the './schema' this pass had just written, turning it into
'../schema/queries' in 22 files. Caught by the resolver check, not by parsing —
both spellings parse fine.

Also corrects every path reference the move invalidated: src/databases/CLAUDE.md's
layout diagram, the root CLAUDE.md data section, three docs, and seven sidecar
comments naming queries/<x>.ts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 01:34:04 +00:00
pastilhasandClaude Opus 5 4cebae1c85 schema barrel: core tables only, plugin tables commented
db:push now creates 24 tables instead of 43. The 19 belonging to sidecars a
light install does not run are commented out in schema/index.ts, kept as the
record of what each table is called and which file defines it.

Core is ecosystem.light plus officer-headscale, plus the app store and
service_connections — app-store/effects.ts reads the latter, so it is core
however few plugins exist. Commented: email, music, notify, dav, photos,
jellyfin, invoiceshelf, soulseek, vault, wallet.

The reason this is only a barrel edit is worth writing down. drizzle.config.ts
points `schema` at schema/index.ts, so that file is drizzle-kit's view of the
schema — but it is NOT the runtime's. Every query imports its table object from
a schema file and calls db.select().from(table), and nothing anywhere uses
drizzle's relational API (db.query.X), which is the only thing the `schema`
passed to drizzle() in db.ts is for. So a commented line removes a table from the
database without removing a line of code.

That only held after moving nine query modules off the barrel and onto their own
schema file — email-accounts, music, notify, photos, jellyfin, invoiceshelf,
soulseek, vault and wallet all imported their tables from '../schema', so
commenting the barrel would have broken the query module, then its export, then
its consumers. dav already did it the right way. With that done the cascade is
gone: everything still compiles, and the tables simply are not created.

Two corrections to what was asked, both checked rather than assumed. The query
barrel (src/index.ts) has no bearing on db:push — drizzle never reads it — so
commenting it would not have kept a single table out of the database. And vault
is not plugin-only from the platform's side: /api/vault is mounted top-level in
hono.ts for the Bitwarden client, and api/vault/router.ts imports getVaultTokens.
Its TABLES are out, but that route still exists and will fail against them.

Not typechecked — empty node_modules, frozen installs — and db:push was not run,
since there is no database here. Every file in the package parses, as does
everything in the tree importing officerdb, and nothing outside the package
reached a plugin table through the exported schema namespace.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 01:24:26 +00:00
pastilhasandClaude Opus 5 9d6c196848 PUBLIC_URL comes back, and gen:index takes it as an argument
Removing PUBLIC_URL earlier today was wrong, and the Build section is where it
would have surfaced: gen-index.ts exits 1 without it, so `bun gen:index` fails,
index.gen.html is never written, and `bun start` has no page to serve.

It came out because origin validation was being discontinued — but that was one
of four consumers and the only one that is gone. gen-index needs it for
OpenGraph tags, which crawlers fetch standalone and cannot resolve relative;
task-api-env builds OFFICER_API_HOST from it; and dav/router hard-requires it,
https only, to build an iOS profile.

It is also the one value this machine genuinely cannot derive, which is what
separates it from DATA_PATH and the rest that left today.

gen:index now takes a URL as its first argument, ahead of the environment and
.env: `bun gen:index https://officer.example.com`. Changing the public address is
one command rather than an edit plus a regenerate, and a second address can be
generated for without touching the install's .env.

It also validates now. A relative or scheme-less value substituted silently and
produced OpenGraph tags nothing can resolve — invisible until someone shares a
link and the preview comes back blank.

.env is PORT, PUBLIC_URL, POSTGRES_URL. Verified by running the section; all
three paths through gen:index exercised (absent, valid argument, invalid).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 01:07:33 +00:00
pastilhasandClaude Opus 5 8207824a81 build the secret store: one key per purpose, none in .env
.env now holds PORT and POSTGRES_URL. Every encryption and signing key lives in
$OFFICER_ROOT/secrets/officer-keys.db — 0600, 0700 directory, owned by the
service user, created on first use.

The design doc planned to move ONE at-rest key into the store. What shipped
splits it: headscale, wallet, photos, jellyfin, invoiceshelf, vault and
service-connections each get their own, plus jwt. VAULT_STORE_KEY encrypted all
seven, so one leak opened all of them — and it was named after whichever plugin
needed it first, which is why it read as safe to change if you did not run a
vault. A core install bootstraps two, jwt and headscale; the rest appear when
their plugin first asks.

The file IS the secret. No second key unlocks it, because a key beside the store
it opens buys nothing. The gain was never secrecy, it is blast radius: bun
auto-loads .env into all twenty pm2 processes, so a key there is readable from
/proc/<pid>/environ of twenty processes — officer-music held the key that
decrypts wallet seed envelopes.

Two defects found by testing the store rather than reading it, both of which
would have shipped:

  The WAL was 0644. Enabling WAL creates -wal and -shm at 0644 rather than
  inheriting the database's mode, and a freshly written key lives in the WAL
  before checkpoint — so the 0600 on the database was decorative. The 0700
  directory covered it, but only until someone loosened the directory.

  PRAGMA journal_mode = WAL takes an exclusive lock, and busy_timeout was set
  AFTER it. With twelve concurrent openers, six died on that line with
  SQLITE_BUSY. Every sidecar opens this store at boot, so they open it
  simultaneously by definition: most of them would have failed to start on a cold
  boot and none on a warm one. Fixed by ordering the pragmas; re-tested with
  twelve racing processes, one key, one row.

crypto.ts takes a purpose as its first argument now, which the design doc had
explicitly promised would not happen — 32 call sites across seven query modules.
That promise is corrected in the doc rather than quietly dropped.

Also live, not just comments: wallet/upstream.ts gated wallet storage on
process.env.VAULT_STORE_KEY and would have reported "unconfigured" forever. It
asks the store now, and the question it answers changed — not "did somebody set a
variable" but "can this process open the store", since the key is created on
demand.

assertSecretsClosed covers the store, its directory and its WAL. The jwt key
mints owner tokens, so a member's shell reading it is strictly worse than the
.env leak that check was written for.

Not typechecked: node_modules is empty and installs are frozen, so the
officerdb/secret-store subpath could not be resolved at runtime here — verified
that officerdb/types fails identically, so it is the empty tree and not the new
export. The store module itself was tested directly: creation, idempotence across
processes, hasKey not creating, permissions, and the twelve-way race. Every
changed file parses; the setup section runs and degrades correctly when the
import is unavailable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 01:00:12 +00:00
pastilhasandClaude Opus 5 1ccb21e67f CLAUDE.md: five capability kinds, not four
The file described a stricter model than the code enforces. It said terminal,
chat, files, tasks, desktop and browser are all `kind: 'execution'` and therefore
never shareable — but terminal, chat and files moved to `confined` on 2026-08-11
with per-user Linux accounts, and are grantable.

The distinction matters and is now written down: a confined grant means nothing
without a Linux user. authorize.ts:97 drops it for an account whose `osUser` is
null, so "granted but unconfined" resolves to no access rather than to the
owner's home — which is what it would otherwise resolve to, since
getOwnerHomeDir ignores the email it is passed. Verified in the code, not
inferred from the comment.

Also brings the Data section in line with today: DATA_PATH, OFFICER_ITEMS_DIR and
HOME_DIR are no longer environment variables, the install root is derived from
cwd, and assertInstallLayout is why a wrong cwd fails instead of relocating the
install. And `bun setup` runs officer-setup.sh, which is sections 1-6 of 10 —
worth saying, since the entry read as though it were finished.

Registry needs real work for where this is going. This is only the docs catching
up to what is there now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 00:43:16 +00:00
pastilhasandClaude Opus 5 9864fb1a49 switch off the browser relay, pending extraction into a plugin
BROWSER_RELAY_PORT is gone and the second listener no longer starts. The Chrome
extension, api/browser/ and the /browser screen all stay on disk — this is going
to be extracted, and deleting it means writing it again.

Three things had to move together, and the middle one would have failed the boot
on its own:

  server.tsx    the listener, commented out with the variable name recorded
  hono.ts       the /api/browser mount, closed
  registry.ts   the 'browser' capability's claim on /browser, dropped

assertCapabilityTotality checks both directions: check 2 refuses to start on a
capability claiming a prefix nothing serves. Unmounting the router alone would
have left the registry describing it, and the server would not have come up.

The capability itself survives because it also claims /scrape, which shares
nothing with the relay — it launches its own headless chromium through playwright
and never speaks to the extension.

The comment in server.tsx carries the two facts that are not recoverable by
reading the remaining code. First, the port is an INPUT TO A CREDENTIAL:
relay-auth.ts derives each extension's token as HMAC(JWT_SECRET,
'officer-browser-relay-v1:${port}:${userId}:${salt}'), so bringing the relay back
on a different number silently invalidates every paired browser — reported by the
extension as "Relay not reachable", which SETUP.md blames on a wrong address,
port or token. Second, it cannot come back as a kernel-assigned port:0 like the
other sidecars: the extension is configured by hand and stores the value, so a
port that moves each restart breaks the pairing each restart.

Left alone deliberately: the /browser route in App.tsx, its Dock entry, and the
Settings → Browser Relay panel. They will not work against a closed endpoint.
Removing them is frontend work for the extraction, not part of switching the
listener off.

.env is down to PORT and POSTGRES_URL.

Not typechecked (empty node_modules, frozen installs). Every changed file parses;
the setup section was run and writes two variables.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 00:32:29 +00:00
360 changed files with 11812 additions and 2984 deletions
+17 -19
View File
@@ -1,29 +1,27 @@
# What officer-setup writes. Everything below this block is optional, or is on its way out.
PORT=9000
BROWSER_RELAY_PORT=18792
POSTGRES_URL="postgres://postgres:password@localhost:5432/officer"
# ── Moving to the secret store ─────────────────────────────────────────────────────────────────
# Still REQUIRED — jwt.ts throws at module load without JWT_SECRET, and crypto.ts throws without
# VAULT_STORE_KEY — but officer-setup no longer writes either. They are moving into the SQLite key
# store (docs/secret-store.md), which is designed and not yet built, so an install made by the
# current script will not boot until it is. That is deliberate sequencing, not an oversight.
JWT_SECRET="<generate with: openssl rand -base64 32>"
# NOT Vaultwarden's, despite the name and where it used to sit — it is the platform's at-rest key,
# encrypting every secret column in Postgres: Headscale admin API keys, app-store service
# credentials, Jellyfin tokens, wallet node credentials, and the wallet seed envelope on top of the
# owner passphrase that seals it.
# Where Officer is reached from a browser — the one value the machine cannot derive. Read by
# `bun gen:index` (OpenGraph tags, which need an absolute URL), the task API host, and the CalDAV iOS
# profile builder, which additionally requires https.
#
# CHANGING IT MAKES ALL OF THAT UNREADABLE AT ONCE, and for the seed that is unrecoverable: the
# passphrase opens the inner envelope and this is the outer one.
VAULT_STORE_KEY="<generate with: openssl rand -base64 32>"
# `bun gen:index https://other.example.com` overrides it for one run without editing this file.
PUBLIC_URL=http://localhost:9000
# ── No secrets live here ───────────────────────────────────────────────────────────────────────
# JWT_SECRET and VAULT_STORE_KEY were here until 2026-08-13. Every encryption and signing key now
# lives in the secret store — a 0600 SQLite file at $OFFICER_ROOT/secrets/officer-keys.db, one key
# per purpose, created on first use. See docs/secret-store.md.
#
# The reason is blast radius rather than secrecy: bun auto-loads this file into ALL of the pm2
# processes, so a key here is readable from /proc/<pid>/environ of twenty processes that mostly have
# no business with it — officer-music held the key that decrypts wallet seed envelopes.
#
# BACK UP THAT FILE. Losing it signs everyone out and makes every encrypted column in Postgres
# unreadable, and for the wallet seed that is unrecoverable.
# ── Optional ───────────────────────────────────────────────────────────────────────────────────
# Where Officer is reached from a browser. Read by origin validation, the task API host check, and
# the CalDAV iOS profile builder — which is the only one that hard-requires it, and demands https.
# PUBLIC_URL=https://officer.example.com
# Guards (CORS origin checks, rate limits, password-strength rules) are ON unless this is set to
# "dev" or "development". Unset is hardened, which is why officer-setup no longer writes it — set it
# by hand, on a local machine you trust, to develop. Note that `bun dev` does NOT set it: that script
+12
View File
@@ -58,3 +58,15 @@ public/plugins/
# Written by officer-setup.sh; per-machine.
scripts/setup/officer-setup/.setup-progress
# Generated by officer-setup, describing THIS install's processes. Never committed:
# the repository has no ecosystem file at all any more, and the next machine
# generates its own. See scripts/setup/officer-setup/lib/services.sh.
ecosystem.config.cjs
# The built SPA and the generated plugin module — both describe THIS install's plugin set and are
# rewritten on every install. See servers/plugins/generate.ts.
build/
build.next/
src/apps/officer-web/Plugins.gen.tsx
src/databases/officer_db/src/plugin-schemas.gen.ts
+56 -23
View File
@@ -16,13 +16,23 @@ written: `users` holds six rows. The accurate statement is narrower and more use
- **Other accounts get only what their ROLE is granted.** Roles are `Admin`, `Member`, `Developer`;
grants live in `role_capabilities`, keyed on role, never on user. Absence denies — there is no row
meaning "no", so an empty table is a server where members reach nothing but their own profile.
- **Some things can never be shared, structurally.** Terminal, chat, tasks, files, desktop and browser
are `kind: 'execution'` in the capability registry: they run as the owner's OS user in the owner's
home, so there is no level of "read" that makes them safe. They have no level at all and the grants
API refuses to store one.
- **Some things can never be shared, structurally.** Tasks, items, desktop and browser are
`kind: 'execution'`: they run as the owner's OS user in the owner's home, so there is no level of
"read" that makes them safe. They have no level at all and the grants API refuses to store one.
- **And some are shared only because the kernel enforces it.** Terminal, chat and files are
`kind: 'confined'`, added 2026-08-11 with per-user Linux accounts. They still touch the filesystem
and still run processes — but not the *owner's*, because the account has its own Linux user, its own
home, and the kernel refusing everything above it.
So "which user is this" now has a real answer for the **app** surface (gitea, music, photos, email,
calendar…), and is still always "the owner" for anything that executes code or touches the disk.
The distinction earns its keep in one place: **a confined grant means nothing without that Linux
user.** `authorize.ts` drops it for an account whose `osUser` is null, so "granted but unconfined"
resolves to no access rather than to the owner's home — which is what it would otherwise resolve to,
since `getOwnerHomeDir` ignores the email it is passed. That rule lives there once and covers the
HTTP routes, the websocket doors and the dock together.
So "which user is this" has a real answer for the **app** surface (gitea, music, photos, email,
calendar…) and for the **confined** one (terminal, chat, files), and is still always "the owner" for
anything under `execution`.
`src/servers/capabilities/registry.ts` is the authority and reads as the design document for this.
**Mounting a router without a registry entry makes the server refuse to boot** — see "Capabilities"
@@ -42,18 +52,20 @@ One Bun process (`src/server.tsx`) serves everything:
- eight WebSocket providers — terminal, chat, task-runner, pipeline, cliamp, cliamp-audio, desktop,
vault — plus a sidecar registration socket. `terminal` is a byte relay onto the pty sidecar's own
listener, not a translating bridge; `vault` is the same shape onto Vaultwarden's notifications hub.
- a browser relay on its own port (`BROWSER_RELAY_PORT`, default 18792)
- ~~a browser relay on its own port~~ — switched off 2026-08-13, awaiting extraction into a plugin.
The extension and `api/browser/` stay on disk; the listener and the `/api/browser` mount do not.
Long-running and privileged work lives in **sidecars**: separate processes that dial back in over
`/api/sidecar/register` and are tracked in `src/servers/sidecar-registry.ts`. PM2 runs them
(`ecosystem.config.cjs`): `officer` (the server), `officer-anthropic-proxy`, `officer-agent`,
(the generated `ecosystem.config.cjs` — see below): `officer` (the server), `officer-anthropic-proxy`, `officer-claude-code`,
`officer-opencode`, `officer-email`, `officer-pty`, `officer-vnc`, `officer-music`, `officer-vault`,
`officer-slskd`, `officer-headscale`, `officer-transmission`, `officer-invoiceshelf`, `officer-wallet`,
`officer-photos`, `officer-notify`, `officer-caldav`, `officer-memos`, `officer-jellyfin`, `officer-gitea`
— twenty as of 2026-08-06, and a list that goes stale every time a sidecar lands. `pm2 jlist` is the
source of truth.
**`officer-anthropic-proxy` and `officer-agent` are not the same thing.** The proxy holds the Anthropic
**`officer-anthropic-proxy` and `officer-claude-code` are not the same thing.** (The second was
called `officer-agent` until 2026-08-13; older docs use that name.) The proxy holds the Anthropic
credential and forwards API traffic; the agent is the process that spawns `claude`. They were one entry
named `officer-claude` until the sidecar-isolation work — which is exactly how the false claim that
"restarting officer doesn't disturb the agent" survived so long. Every sidecar is a PM2 peer of
@@ -112,16 +124,27 @@ imported by their package name (`officerdev`, `hooks`, `state`, `types`, `helper
Two stores, and the split matters:
**Postgres** (`src/databases/officer_db`) holds the account, passkeys, settings, dashboards,
email accounts, queue and pipeline jobs. Schema in `src/schema/`, hand-written queries in
`src/queries/`, types inferred from the schema in `src/types.ts`.
email accounts, queue and pipeline jobs. One directory per feature holding `schema.ts` and
`queries.ts` beside each other; `src/schema.ts` is what `db:push` reads, and it lists the core tables
with the plugin ones commented out. Types inferred from the schema in `src/types.ts`.
**The filesystem** holds everything the agent authors. `OFFICER_ITEMS_DIR` contains one directory
per item under `skills/`, `tools/`, `tasks/`, `processes/`, `extensions/` — no database rows, no
scope tiers. `DATA_PATH/<email>/` holds the managed home, attachments and the per-account email SQLite
stores — those are the **email sidecar's**, and nothing in the platform opens them. Path helpers live in
`src/servers/data-path.ts`; note `getHomeDir` (the managed home under
`DATA_PATH`) versus `getOwnerHomeDir` (the owner's real login home when `HOME_DIR` is set, which is
where terminals, chats and task runs actually execute).
**The filesystem** holds everything the agent authors. `OFFICER_ITEMS_DIR` (`$OFFICER_ROOT/capabilities`)
contains one directory per item under `skills/`, `tools/`, `tasks/`, `processes/`, `extensions/` — no
database rows, no scope tiers. `DATA_PATH/<email>/` holds the managed home, attachments and the
per-account email SQLite stores — those are the **email sidecar's**, and nothing in the platform opens
them.
**None of those paths is configured.** Since 2026-08-13 `src/servers/data-path.ts` derives the install
root as `resolve(process.cwd(), '..')` and hangs `data/`, `capabilities/` and `dockers/` off it. That
replaced `DATA_PATH`, `OFFICER_ITEMS_DIR` and `HOME_DIR` in `.env` — three values that had to agree with
each other and with the tree on disk. `assertInstallLayout` refuses to boot when the working directory
is not the repo, because otherwise a wrong `cwd` relocates the whole install silently rather than
failing.
Note `getHomeDir` (the managed home under `DATA_PATH`, now used only for NON-owner accounts and by
pipeline-executor) versus `getOwnerHomeDir` (the owner's real login home, where terminals, chats and
task runs execute — captured from `homedir()` once at module load, and it ignores the email it is
passed).
### Schema changes use `push`, not migrations
@@ -163,11 +186,13 @@ valid"). It is `_middlewares/capability-gate.ts` → `capabilities/authorize.ts`
ahead of everything, and it re-verifies the token itself so it covers routes that never mount
`userMiddleware`.
- `capabilities/registry.ts` — the single enumeration of what the platform can do, in four kinds:
`core` (every account, not deniable), `app` (**the grantable surface**), `execution` and `admin`
(owner only, and `execution` is never grantable at any level).
- `capabilities/registry.ts` — the single enumeration of what the platform can do, in five kinds:
`core` (every account, not deniable), `app` (**the grantable surface**), `confined` (grantable, but
only to an account that has a Linux user), `execution` and `admin` (owner only, and `execution` is
never grantable at any level). 27 entries as of 2026-08-13.
- `capabilities/authorize.ts` — resolves "may this account do this". Owner short-circuits first; every
other answer is role grants plus core, with `execution`/`admin` stripped even if a row grants them.
other answer is role grants plus core, with `execution`/`admin` stripped even if a row grants them,
and `confined` stripped for an account with no `osUser`.
**Every catch returns deny.** Grants are cached by role and the cache's whole invalidation contract
is `invalidateRoleGrants`, called by the one writer in `api/users/capabilities-routes.ts`.
- `capabilities/totality.ts``assertCapabilityTotality` runs in `server.tsx` **before `serve()` and
@@ -194,9 +219,17 @@ bunx tsgo # typecheck (not tsc)
bun test # tests
bun format # prettier over every dirty file — see the note below before running it
bun db:push # apply the schema to Postgres
bun setup # guided install (writes .env, incl. PUBLIC_BUILD_ENV=production)
bun setup # runs scripts/install.sh — blank machine to running platform
```
`scripts/install.sh` is only an orchestrator — it runs the two halves in order and does nothing itself:
`setup/machine-setup/machine-setup.sh` (28 sections: packages, tailnet, runtimes, docker, shell) then
`setup/officer-setup.sh` (11: pre-flight, layout, repository, dependencies, database, environment, secrets,
schema, build, services, verify). Either runs alone — `--machine-only`, `--officer-only`, or by path — because
a machine you already trust needs only the second. Both are re-runnable: each records the steps it finished
and skips them, so stopping halfway costs nothing. **Run it as yourself**; it re-execs through `sudo` when it
needs to, and on macOS never does, because Homebrew refuses to run as root.
Sidecar control is PM2, not npm scripts: `pm2 restart officer-<name>`, `pm2 logs officer-<name>`.
See `docs/working-on-officer.md` for which process a given change needs restarted.
+10
View File
@@ -80,6 +80,16 @@ the owner's OS user and can never be granted. Indirection there really is accide
lookup, the role cache and the fail-closed catches is exercised only by hand. It is the file
standing between a Member and a shell.
- [ ] **`assertCapabilityTotality` checks the wrong list, and `registry.test.ts` has been red since
2026-08-13.** It is fed `Object.keys(handlers)` from `server.tsx`, but Bun serves the *route table*.
Those diverged when the cliamp/desktop/vault plugins were switched off: `/api/cliamp/ws` and
`/api/cliamp/audio/ws` are still live routes with their handlers and registry claims commented out.
Not exploitable — `isWsProviderAllowed` finds no capability and 403s a member; the owner upgrades onto
a dead socket. But the boot check that exists to stop exactly this cannot see it. Two fixes: point
totality at the route table, and either delete the dead routes or restore their claims. The 8 failing
tests in `registry.test.ts` are the same drift — `REAL_WS` still lists all nine providers as served,
which is why nobody noticed. Found 2026-08-14.
- [ ] **No empty state for a denied screen.** A member who reaches a route their role lacks gets a
broken panel or an endless spinner rather than a clean refusal.
+10 -1
View File
@@ -22,4 +22,13 @@ env = "BUN_PUBLIC_*"
coverage = true
coverageDir = "coverage"
preload = ["./test-setup.ts"]
root = "./src"
# The repo, not just `src` — a plugin's tests are the platform's tests.
#
# This was "./src" until 2026-08-15, when music became `plugins/music/` and took `lyrics.test.ts` with
# it. `bun test` then stopped running it and said nothing: 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.
#
# Positional filters do not help — `bun test plugins` matches paths UNDER root, so it finds
# `src/servers/plugins/` and not `plugins/`. Root is the only lever.
root = "."
+84
View File
@@ -0,0 +1,84 @@
# The documentation, triaged
**2026-08-13.** A map of what is in here, what it is for, and what should happen to it. Made because
there are 42 documents and 13,000 lines, and no way to tell from the filenames which describe the
system as it is and which are a record of an afternoon in July.
**How much I verified:** the classifications below are from filenames, status lines, and greps for
things that changed on 2026-08-13. Where I actually read the document or checked the code, it says
so. The rest is a starting point for a conversation, not a verdict.
---
## Living — these describe the system and must stay true
| doc | state |
| --- | --- |
| `working-on-officer.md` | **updated 2026-08-13.** Operational guide. |
| `secret-store.md` | **updated 2026-08-13.** Built; rotation still open. |
| `install-variants.md` | new. The branch tree, for discussion. |
| `http-secure-context-audit.md` | new. What breaks over plain http. |
| `install-container-testing.md` | new. First container pass and its findings. |
| `per-user-linux-accounts.md` | partly updated. `OFFICER_OS_USERS` is gone; check the rest. |
| `navigation-audit.md` | authoritative on routing. Unverified against tonight's route removals. |
| `workspace-panels.md` + `workspace-panel-todo.md` | the panel framework. 1,300 lines combined — likely the biggest cleanup here. |
| `agent-coordination.md` | the north star for panel work. |
| `deprovision-os-account.md` | implemented; the `'disabled'` stage it may mention was deleted tonight. |
## Stale — describe things that changed on 2026-08-13
Each of these references something that no longer exists. **Not yet corrected.**
- `sidecar-topology.md` — "ecosystem.config.cjs is the source of truth". It is generated now, and
holds six processes.
- `sidecar-app-store.md` — derives the catalogue from `full light`. Those files are gone, and
`catalogue.test.ts` was rewritten.
- `sidecar-bootstrapping.md` — "20 PM2 entries, 18 sidecar dirs". Six entries now.
- `mobile-api-keys.md` — partly corrected; recheck the origin-checking claims.
- `wallet-key-custody.md``VAULT_STORE_KEY` is now the per-purpose `wallet` key.
- `push-notifications.md` — "agreed design, 2026-07-31". Notify is a plugin and unmounted.
- `chat-session-lifetime.md`, `chat-ui-walkthrough.md` — reference `officer-agent`, renamed.
## Historical — a record of a moment, and should stay one
Do **not** rewrite these to match today's code. They document how a decision was reached, and
editing them destroys the reasoning. If they mislead, add a dated header pointing forward.
- `sidecar-audit-2026-07.md` (1,377 lines)
- `claude-sidecar-isolation.md` — records the `officer-claude``officer-agent` rename that
preceded tonight's `officer-agent``officer-claude-code`
- `open-threads-after-per-user-claude.md`
- `two-agent-field-report-2026-08-12.md`
- `api-method-changes-2026-08-06.md`
## The opencode cluster — nine documents for one migration
`opencode-fork-decision` · `-parity` · `-api-2-assessment` · `-phase0-review` · `-phase1-report` ·
`-phase1-review` · `-serve-migration-plan` · `-serve-path` · `-testing-checklist`
**The migration landed**`opencode serve` is in the sidecar, verified. So
`opencode-serve-migration-plan.md` saying "Nothing here is implemented" is false.
This is the clearest consolidation candidate in the whole directory: one document recording what was
decided and what shipped, replacing nine that describe stages of getting there. I did not do it
because it needs reading all nine, and deleting documents unread is not a thing to do at 4am.
## The mobile-dav thread — three documents, one conversation
`mobile-dav-provisioning` · `-feedback` · `-reply`. A correspondence. Almost certainly one document.
## Unclassified — I have not looked
`design-language-interface` · `file-sync` · `jobs-unification` · `mobile-photo-sync-api` ·
`nextcloud-replacement` · `agent-git-identity`
---
## The plugin split, which affects most of the above
A core install is six processes. **Everything else is a plugin**, switched off tonight but present on
disk. Most documents here were written when the estate was twenty processes and every one of them was
simply "there", so they describe availability that no longer holds.
The useful rewrite is usually one line, not a rewrite: say whether the thing described is **core** or
**a plugin**, and if a plugin, that it is not mounted on a fresh install.
-250
View File
@@ -1,250 +0,0 @@
# Waits: how an agent waits for something without burning context
**Status:** draft, 2026-08-13. One mechanism proven (git remote polling, run twice); everything else here is
specification and reasoning. Claims are marked **measured** or **reasoned** — do not let that slip.
`docs/two-agent-field-report-2026-08-12.md` describes this for one purpose: one agent waiting on another's
push. That was where it was discovered, not where it belongs. This file is about the primitive itself,
because the same shape answers "wait for CI", "wait for the job to finish", "wait for the container to go
healthy", "wait for a reply", and a dozen other things Officer already needs.
---
## The primitive
> A **wait** is a harness-owned process that blocks until a condition holds, then exits — and whose exit
> re-invokes the agent.
Three properties. Drop any one and it breaks in a way that is not visible from watching it run:
1. **The waiting happens below the model.** No inference per tick. The agent is suspended.
2. **The harness owns the process**, so its exit is an event the harness delivers. A process the harness is
not tracking can finish perfectly and tell nobody.
3. **It exits when it has something to say.** The exit *is* the notification. A wait that detects and keeps
running has informed no one.
Everything below follows from those three.
---
## The cost model, which decides everything else
This is the part that is easy to get half-right, and half-right is what leads people to build the expensive
version.
| | cost |
|---|---|
| a tick while waiting | **nothing** — no model runs |
| a thousand ticks | **nothing** |
| **each wake** | a full context read, uncached |
**Measured** (field report, 2026-08-12): an idle watcher produced 85 bytes over seven minutes with zero
inference. **Measured** tonight: two fires, each costing exactly one wake.
**Reasoned, and the part usually missed:** a wake re-reads the entire conversation, and conversations only
grow. So the cost of a wait is not `duration` — it is `fires × context-at-the-time`. Idle is free forever;
the tenth notification in a long session costs several times the first.
Worse, waits are the exact workload the prompt cache cannot help. The TTL is about five minutes; anything
worth waiting for takes longer than that. **Every wake is an uncached read, by construction.**
Two consequences that should drive design:
- **Say less on wake.** The output that survives to the wake enters the context permanently. One line per
tick over 24h is 2,880 lines that land at once and then stay.
- **Prefer many short sessions to one long one.** A wait in a fresh session costs a constant amount per
event. The same wait in an immortal session costs monotonically more. This is the single strongest
argument for event-driven agents over resident ones.
---
## Prefer blocking over polling. Prefer events over both.
The git watcher polls because a git remote can only be *asked*. Most things Officer waits on are not like
that, and a poll is the worst of the three options that usually exist.
**Tier 1 — block on the kernel.** Zero syscalls while waiting, and detection is immediate rather than
average-half-an-interval late.
| waiting for | how to block |
|---|---|
| a file appearing or changing | `inotifywait -q -e close_write,create,moved_to <path>` |
| a process to exit | `tail --pid=<pid> -f /dev/null` |
| a lock to release | `flock <file> true` |
| a line on a pipe or log | `read -r line < <fifo>` |
| an inbound HTTP callback | a listener that blocks on `accept()` |
| whichever of several finishes first | `wait -n` over background pids |
**Tier 2 — block on the service.** Some services will hold a connection open and tell you.
| waiting for | how |
|---|---|
| a row to change | Postgres `LISTEN` / `NOTIFY` — the connection blocks, the database pushes |
| new mail | IMAP `IDLE` |
| a container to change state | `docker events --filter …` (streams, blocks) |
| a systemd unit | `systemctl --wait` / journal follow |
Officer keeps almost everything in one Postgres. `LISTEN`/`NOTIFY` is therefore the highest-leverage
unbuilt piece here: job completion, a new chat message, a status flip, all become blocking waits with no
polling anywhere.
**Tier 3 — poll, because the source can only be asked.** A git remote, a third-party HTTP API, a health
endpoint. Then the rules are: read-only calls (`git ls-remote`, never `git fetch` — a fetch mutates refs
under a working tree that may be mid-edit), a `timeout` on every call so a hung network call cannot leave
the wait alive and blind, and an interval matched to how fast the thing actually changes.
**Never poll in the model.** A scheduled wake-up, a `/loop 30s`, a "check every minute" — these are the same
shape wearing the same clothes and they pay a full uncached context read *per tick* to learn nothing. This
is the intuitive design and its expense is invisible, which is why it needs saying first.
---
## Make firing mean something
The rest of this file is about how to wait cheaply. This section is about the other half, and it is the one
that decides whether a fleet of these is affordable.
**Most waits find nothing, almost always.** A daily release check answers "no" 360 days a year. A branch
watcher wakes on every push, including everyone else's. So the number that matters is not the cost of a
useful wake — it is the cost of a useless one, multiplied by how many there will be.
The fix is not a cheaper wake. It is to **push the relevance test into the wait condition**, so that firing
already implies relevance:
- **Do not** wait on "a push", then wake and check whether it carries a `COMMS/<branch>/NN-*.md`. Wait on a
push *that contains one* — a filename test the shell can do with no model at all.
- **Do not** wait on "the releases page changed", then wake and read it. Wait on "the version string differs
from my cursor" — a string compare.
Three tiers, and almost everything should die at the first:
| tier | cost | for |
|---|---|---|
| **shell condition** | zero | anything expressible as a filename, a diff, a version, a status |
| **fresh minimal agent** | one small cold read | relevance genuinely needs judgement, but not history |
| **escalate with real context** | a full read of a long session | the event has to be interpreted against what came before |
A session fork that inherits context but returns nothing to it (Claude Code's `/btw`) is tier two done well.
It is still a context read, so it is the fallback when a shell test cannot express relevance — not the
default.
**Corollary for the platform:** a wait's condition should be part of its declaration, not something the agent
evaluates after waking. `wait for: push to <branch> touching COMMS/**` is a cheaper and more honest thing to
build than `wait for: push` plus an agent that decides.
## The contract a wait must honour
Specification. None of this is built yet.
**Exit codes are the vocabulary.**
```
0 fired — the condition holds; payload on stdout
1 timed out — the bounded lifetime elapsed, nothing happened
2 broke — the wait itself failed and is no longer trustworthy
```
`1` and `2` must be distinguishable. "Nothing happened" and "I stopped being able to tell" are opposite
facts and a wait that conflates them is worse than no wait, because absence reads as reassurance.
**Output is a payload, not a log.** One line on arm so there is a record of what was watched; silence while
waiting; a minimal structured payload on fire. Everything printed is permanent context.
**A cursor, persisted.** The wait is armed at a position — a SHA, a byte offset, a row id, a timestamp — and
that position belongs on disk, not only in the process. Then a re-arm after a restart neither misses events
nor re-reports old ones. The git watcher currently holds its base only in memory, which is why a session
restart loses the thread.
**Bounded lifetime, and the bound is not "forever".** `seq 1 2880` is a runaway backstop, not a policy. A
wait that times out should re-arm from its cursor rather than die silently.
**Liveness must be externally checkable.** A dead wait and a quiet one are indistinguishable, and that
ambiguity has already cost two missed pushes. Cheapest fix: touch a heartbeat file each tick, so `mtime`
answers "is it alive" without asking the process. In a UI that shows running processes — as Officer's chat
does — the chip itself is the signal, which is a real advantage and should be kept.
**Idempotent re-arm, and self-trip protection.** An agent that acts and then wakes on its own action is a
loop. Re-arm from the position *after* your own change, and never run two waits on the same condition.
---
## Where this applies in Officer
The reason to generalise. Each of these is a place something currently either blocks a turn, gets polled by
a human, or is discovered late.
| wait | tier | notes |
|---|---|---|
| a pipeline/script job finishes | 1 or 2 | `data/jobs/<id>.log` is a file — inotify. Or `NOTIFY` on the row |
| a download completes | 1 | same, and the progress sentinel already exists |
| a container becomes healthy | 2 | `docker events` |
| a member logs into `claude` for the first time | 1 | `~/.claude/.credentials.json` appearing — currently polled by `/agent-status` |
| new mail arrives | 2 | IMAP IDLE, in the email sidecar |
| CI, a deploy, a remote build | 3 | poll, with a timeout |
| a push to any repo | 3 today, **event tomorrow** | Gitea is ours: a webhook removes the wait entirely |
| a long `db:push` or migration finishes | 1 | process wait |
| disk crosses a threshold | 3 | slow-moving; poll infrequently |
| **a human replies** | 1 | an approval gate: the agent arms a wait and stops costing anything until answered |
That last row is the one worth dwelling on. An agent that needs a decision currently either blocks a session
or asks and forgets. A wait makes "stopped, pending your answer" cost nothing while it lasts.
---
## Choosing a lifetime
| shape | when | cost |
|---|---|---|
| **wait inside a live session** | the agent holds context the event needs interpreting against | free while idle, growing per fire |
| **wait, then hand off** | context matters up to the fire, not after | one growing session, then reset |
| **no wait — event spawns a fresh agent** | the event carries everything needed (a SHA, a job id) | constant per event, forever |
The third is the destination for anything recurring. The first is right for tonight's watcher, where the
value is that I already know what the commits mean.
The rule: **if the payload plus the repo is enough to act on, do not keep a session alive to receive it.**
---
## Failure modes
| pattern | what it looks like |
|---|---|
| **launched outside the harness** | `nohup … &` — runs, detects, exits, and no one is told. Looks perfect |
| **model-driven poll** | correct behaviour, full context read per tick |
| **detects but does not exit** | prints "found it" into a file nobody reads |
| **chatty** | per-tick output, deferred, all landing at once on wake |
| **silent death** | session restarts, wait dies, quiet branch and dead watcher look identical |
| **self-trip** | agent's own push wakes it, usually because an old wait was never stopped |
| **timeout mistaken for quiet** | exit 1 treated as "nothing happened" when it means "I stopped looking" |
| **mutating poll** | `git fetch` in a loop, moving refs under a working tree |
---
## Open questions
1. **Is a wait a platform feature or an agent habit?** Officer has a job runner, a Gitea instance and a
sidecar pattern. `POST /waits {condition, payload}` returning when it fires is a plausible platform
primitive — and would make waits available to capabilities, not only to agents.
2. **What arms a wait for an agent that is not running?** The webhook shape needs the platform to spawn the
agent, which is `send-claude-code` plus a trigger. Most of that exists.
3. **Should waits be declarative?** `wait for: file:<path>` / `pg:notify:<channel>` / `git:<remote>/<branch>`
— a small vocabulary compiled to the right tier, so nobody hand-writes a poll for something inotify could
have blocked on.
4. **How does a wait survive a session restart** without either missing its event or re-firing on an old
one? The cursor answers half of it; the other half is who re-arms.
5. **What is the right granularity of notification?** One wake per push, or one wake per batch after a quiet
period? Batching trades latency for context, and context is the scarce thing.
---
## Provenance
The mechanism, the three properties and the four wrong ways to launch it come from
`docs/two-agent-field-report-2026-08-12.md`, which recorded them after they were learned the hard way. What
this file adds is the cost model stated as a formula rather than an anecdote, the block-over-poll hierarchy,
the exit-code contract, and the argument that the destination is event-spawned short-lived agents rather
than resident ones.
Nothing in "the contract" or "where this applies" has been implemented. The only thing running today is a
tier-3 git poll, which is the good version of the wrong shape.
+84
View File
@@ -0,0 +1,84 @@
# What breaks over plain http
**Audited 2026-08-13**, after `crypto.randomUUID` took the chat page down at the end of every turn.
Officer is reached at `http://officer-dev:9000` — a tailnet address, so **neither https nor
localhost**, and therefore not a [secure context]. A set of browser APIs are unavailable there by
specification, not by policy, and there is no flag that changes it.
The failure mode is what makes this worth a document. Two of the three shapes below are silent:
| shape | what a user sees |
| --- | --- |
| `crypto.randomUUID()` | `TypeError` — and if it is inside a `useState` initialiser, the whole tree unmounts |
| `navigator.clipboard.writeText()` | `TypeError`, killing the click handler |
| `navigator.clipboard?.writeText()` | **nothing at all** — the button reports success and copies nothing |
The optional-chained one is the worst: indistinguishable from working until somebody pastes.
---
## Fixed
### `crypto.randomUUID` — 18 call sites
Secure-context only. `crypto.getRandomValues` is **not** — it lives on `Crypto` rather than
`SubtleCrypto` — so `helpers/random-id.ts` builds the same v4 UUID from the same CSPRNG when
`randomUUID` is absent. Same entropy, same version and variant bits.
### `navigator.clipboard.writeText` — 20 call sites across 18 files
Secure-context only. `helpers/clipboard.ts` falls back to `document.execCommand('copy')` over an
off-screen textarea, which predates the secure-context rule and works on any origin. Deprecated and
working beats modern and absent.
One call site carried the comment *"Officer is always behind HTTPS"*. It was not.
---
## Cannot be fixed this way
### `navigator.clipboard.read()` — pasting a file in the file browser
No fallback exists. `document.execCommand('paste')` was never permitted from script, so on an
insecure origin there is no way to pull clipboard contents on demand — only a real paste event the
user initiates, which is a different interaction. Now guarded by `canReadClipboard()` and refuses
with an explanation instead of throwing.
### `getUserMedia` — audio recording, 4 files
`apps/Chat/useAudioRecording.ts`, `apps/FileBrowser/.../DictateDialog.tsx`,
`apps/QrTransfer/Receiver.tsx`, and a test. Requires a secure context and cannot be polyfilled — the
browser will not hand out a microphone or camera over http.
**Being removed** rather than guarded: the owner uses an external dictation app. Note `QrTransfer`
uses it for the CAMERA rather than a microphone, so removing "audio" does not cover it — that one
needs its own decision.
### `navigator.credentials` — passkeys
WebAuthn is secure-context only. `helpers/passkeys.ts` exists and cannot work over http, whatever is
done to it. Not currently reachable, so nothing is broken today.
---
## Checked and clear
- **`crypto.subtle`** — not used anywhere in the frontend. This was the one worth confirming, since
it would have had no cheap fallback.
- **`Notification`** — the six matches are type names, not the browser API. Nothing calls
`new Notification` or `requestPermission`.
- **Service workers, WebUSB, WebSerial, WebBluetooth, Payment Request, Wake Lock, Storage Manager,
`SharedArrayBuffer`** — not used.
- **`navigator.geolocation`** (`widgets/Weather`) — secure-context only, but already guarded with
`if (!navigator.geolocation) return;`, so it degrades rather than throws. The widget simply cannot
locate you over http.
- **`navigator.share`** (`Headscale/InvitesView`) — already guarded with a `typeof` check, and its
comment notes it is absent on desktop browsers anyway.
- **WebSockets, IndexedDB, localStorage, EventSource** — no secure-context restriction. Chat,
terminal and the sidecar transports are unaffected.
---
## The alternative
All of this disappears with a certificate, and `tailscale cert` issues a real one for the MagicDNS
name in about one command — no public DNS, no port 80 challenge, no renewal to remember. Worth
knowing that the choice here was "make it work over http", not "http is the only option".
[secure context]: https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts
+74
View File
@@ -0,0 +1,74 @@
# Testing the installer in containers
**2026-08-13.** First pass. Ubuntu 24.04, Debian 12, Arch, Fedora 41.
## What passed
**OS and package-manager detection is correct on all four.**
| image | `OS` | `PM` |
| --- | --- | --- |
| ubuntu:24.04 | `ubuntu` | `apt` |
| debian:12 | `debian` | `apt` |
| archlinux | `arch` | `pacman` |
| fedora:41 | `fedora` | `dnf` |
**`--help` and argument handling work unprivileged** in a clean container, before any escalation.
**The install report is written**, end to end, in a container that had never seen this code. That is
task 1's mechanism confirmed outside the machine it was written on.
**Refusing beats hanging.** With no answer available the run stopped with
`FAIL: No answer. Set ASSUME_YES=1 to run without prompts.` rather than blocking forever on a prompt
nobody could see. That is the behaviour an unattended run needs, and it already exists.
---
## What it found
### 1. `--only` does not isolate a step
Running `--only "Core utils"` still **created a user account**, because `ask_username` and the
account creation happen in the preamble, above the step framework. Everything before the first
`step` call runs on every invocation.
Defensible — every step needs to know who it is installing for — but it means `--only` is not the
surgical tool it appears to be, and a first-time reader will assume it is. Either the preamble
becomes lazy, or `--only` says plainly what it will still do.
### 2. `.setup-answers` travels with a copy of the tree
It lives at `scripts/setup/machine-setup/.setup-answers`, is correctly gitignored, and is `0600`
root-owned. But it is **inside the repository directory**, so `cp -r` or a tarball of the tree
carries it — which is exactly what happened here: a container that had never run setup came up
already knowing the username `pastilhas` and created that account.
Not a leak (username and install path, nothing secret). It is a surprise, and surprises in an
installer are the expensive kind. Worth moving outside the repo, next to the progress file.
### 3. `adduser` leaks its own prompts
```
Use of uninitialized value $answer in pattern match (m//) at /usr/sbin/adduser line 848.
Try again? [y/N]
```
The account-creation path reaches an interactive `adduser` question the script does not answer.
Harmless here because the run stopped anyway, but on a real unattended install this is a hang.
---
## Coverage this cannot reach
Containers have no init by default, so **`systemctl`, netplan, ufw and the sshd drop-ins were not
exercised**. Those sections can only be verified as "wrote the right file", not "the service came
up". Running privileged containers with systemd would close most of that gap and is the obvious next
step.
**Docker-in-Docker** was not attempted, so the Docker section and Postgres provisioning are
untested. Mounting the host socket would test the section's logic while telling us nothing about the
install path.
**macOS is untestable here entirely.** The 17 skipped sections, the Homebrew paths, the Xcode
command line tools step and the refusal-to-run-as-root are all reasoned from documentation and
unverified by execution.
+99
View File
@@ -0,0 +1,99 @@
# The install page, and the scripts behind it
**Status: for discussion, 2026-08-13.** Nothing here is built. It exists so tomorrow's conversation
is about real branches rather than sketched ones — every question below is one the scripts already
ask today.
## The shape agreed
- One **source** — the interactive scripts as they are.
- A **build script** that compiles them into single files, because `curl | bash` cannot fetch libs.
- The build emits **one script per leaf** of the question tree, not one script with pre-seeded
answers. A person auditing before running reads only their own path.
- Verification is of the **generator**, once: anyone regenerates the leaves from source and diffs
them against what is published. One thing to trust rather than N.
---
## The questions that actually exist
Forty-seven prompts across the two scripts. Almost none of them should become a branch — the
distinction that matters is:
**A branch** changes which *code* runs. Removing it makes a script genuinely shorter.
**A value** changes a *string*. Removing it makes a script no shorter — it just moves the answer
from a prompt to a constant.
**A consent** is a yes/no about doing a step at all. These are the interesting middle: pre-answering
one lets the build delete the section entirely.
### Branches — these change what code exists
| question | answers | what it eliminates |
| --- | --- | --- |
| operating system | macOS · Debian/Ubuntu · Arch · Fedora | 17 of 26 machine-setup sections on macOS; the whole `case $PM` ladder collapses to one arm |
| machine role | homelab · vps · dev | swap, ballast, earlyoom, sleep/suspend, boot-hang, static addressing — each is role-gated today |
| tailnet | already connected · set one up · none | the entire Tailscale section, its four sub-options and the offscale explanation |
| which half | machine + officer · officer only · machine only | one of the two scripts disappears |
### Consents — pre-answering deletes a section
Docker · fail2ban · unattended-upgrades · Neovim · agent CLIs · shell config · firewall · SSH
hardening · DNS · swap · ballast · earlyoom · inotify · boot-on-start.
Fourteen sections that a leaf script can simply not contain.
### Values — never a branch
Username · install path · git name and email · port · public URL · Postgres connection · timezone ·
locale · LAN CIDR · swap size · swappiness.
These stay as prompts even in a generated script, or arrive as environment variables. Baking them
into a published file would mean publishing somebody's hostname.
---
## Where this collides with `--unattended`
`--unattended` and a generated leaf are the *same mechanism seen twice*: both are "answer these in
advance". The difference is only whether the answer is baked in at build time or supplied at run
time.
Worth deciding tomorrow whether a leaf script is literally `base.sh --unattended` with a header of
constants, or whether the build truly strips the dead branches. The second is what makes it
auditable-by-being-short; the first is what makes it maintainable. **They are not the same artifact,
and the whole plan rests on which one we mean.**
One thing that already exists and should be preserved either way: with no tty, `install_config`
keeps the user's file rather than replacing it. Every unattended answer needs to be conservative in
that same way, and that is a property of each prompt, not of the flag.
---
## The combinatorics
4 OS × 3 roles × 3 tailnet states = **36 leaves** before any consent is considered, and consents
multiply it past anything anyone would publish.
So the tree the install page walks cannot be the full product. Two ways out, to choose between:
1. **Publish a few opinionated leaves** — "Ubuntu VPS, new tailnet", "macOS dev machine", "Ubuntu
homelab, existing tailnet" — and send everything else to the full interactive script.
2. **Generate on demand** — the page composes the leaf when the questions are answered. Stronger, but
the artifact is no longer a static file anyone can diff against the repo, which costs the
verification property the whole design was for.
My inclination is (1), because (2) quietly trades away the thing that made per-leaf scripts worth
building. But it is a real trade and it is yours.
---
## Open, for tomorrow
- Does a leaf strip dead code, or set constants and call the base?
- How many leaves get published, and what happens to the rest?
- Does the install page show the script before running it? It should — that is the moment auditing
is cheap and nobody will do it afterwards.
- The report from `install-report.md` names a script commit. A generated leaf needs to name the
source commit it was generated from, or the report cannot be checked against anything.
+1 -1
View File
@@ -327,4 +327,4 @@ service verbs exist (`listApiKeys`, `revokeApiKey`) if that changes.
bearer string into a caller. All four doors call it: `userMiddleware`, `originScopeMiddleware`, the
WebSocket upgrade in `server.tsx`, and the vault socket.
- `src/servers/api/api-keys/router.ts` — the three endpoints.
- `src/databases/officer_db/src/schema/api-keys.ts` — the table, and why it stores what it stores.
- `src/databases/officer_db/src/api-keys/schema.ts` — the table, and why it stores what it stores.
+43 -17
View File
@@ -1,11 +1,19 @@
# The secret store
**Status: DESIGN, agreed in conversation 2026-08-12. Nothing implemented.** Every fact below about the
current code was checked against the tree on that date; the file:line references are live.
**Status: BUILT 2026-08-13.** `src/databases/officer_db/src/secret-store.ts`, with `jwt.ts` and
`crypto.ts` reading from it and `officer-setup.sh` section 7 bootstrapping it. Rotation is NOT built —
the schema carries `retired_at` and the API exposes `retiredKeys()`, but nothing retires or re-encrypts
yet.
A small SQLite database, created during setup, holding every encryption and signing key the platform
uses. It replaces `VAULT_STORE_KEY` and `JWT_SECRET` in `.env`, and it is the facility a plugin uses
instead of inventing its own.
A small SQLite database holding every encryption and signing key the platform uses. It replaced
`VAULT_STORE_KEY` and `JWT_SECRET` in `.env`, and it is the facility a plugin uses instead of inventing
its own.
**One change from the design below: keys are per PURPOSE, not one key for everything.** The original
plan moved a single at-rest key into the store. What shipped gives `headscale`, `wallet`, `photos`,
`jellyfin`, `invoiceshelf`, `vault` and `service-connections` a key each, so one leaked key opens one
plugin's columns rather than all seven. `jwt` is the eighth. A core install bootstraps two — `jwt` and
`headscale` — and every other purpose is created when its plugin first asks.
---
@@ -69,7 +77,18 @@ both to still exist. That is a table with `id, purpose, key, created_at, retired
as an environment variable or a single-value file. Concurrent access from several sidecars is the second
reason; SQLite's locking is the part a hand-rolled file store gets wrong.
### 2. It is NOT encrypted at rest, for now
### 2. It is NOT encrypted at rest — and that decision changed shape
**As built, the file IS the secret.** Keys are stored as they are used, with no second key unlocking
them, because a key sitting beside the store it opens buys nothing: whoever can read one can read the
other. The boundary is `0700` on the directory, `0600` on the file, owned by the service user.
That answers open question 4 below — nothing stays outside, and `.env` holds no secret at all.
The original reasoning for encrypted-values-in-a-plaintext-file is kept below because the SQLCipher
finding is still true and still the reason whole-file encryption is not on the table.
#### The original note
Checked rather than assumed, because `PRAGMA key` appears to work and does not:
@@ -94,25 +113,30 @@ trade and it is written down here so nobody later assumes the file is opaque.
### 3. Where the file goes
**`$OFFICER_ROOT/secrets/officer-keys.db`** — a sibling of `platform/` and `data/`, decided 2026-08-13.
**Not in `$OFFICER_ROOT/data/`.** That directory holds managed homes and attachments — it is the one
people back up. A key store that travels in the same tarball as a database dump rebuilds the exact
problem this design exists to avoid.
`[open]` The location. It needs to be somewhere a routine backup does not sweep up, or somewhere
documented loudly enough that a backup script excludes it deliberately.
The setup script says so out loud when it creates the store, because "back this up, but not next to the
other thing you back up" is not a rule anyone infers.
### 4. One secret remains outside
### 4. ~~One secret remains outside~~ — none does
The store's own key — whatever unlocks the values inside it. That is unavoidable and is the point of the
whole exercise: **N secrets in twenty process environments becomes one secret, read on demand, by the
two processes that need it.**
Answered 2026-08-13: **no secret remains in `.env`.** The store file is the secret, per decision 2.
`[open]` Whether that one secret stays in `.env` — which reintroduces the auto-load problem for exactly
one value — or comes from a file read on demand.
The point of the exercise still holds, and it was always about blast radius rather than secrecy: **N
secrets in twenty process environments becomes a file read on demand by the few processes that need
it.** `.env` is auto-loaded by bun into every pm2 process, so a key there is readable from
`/proc/<pid>/environ` of twenty processes — `officer-music` held the key that decrypts wallet seed
envelopes. A file opened by the two or three processes that actually use a key does not.
### 5. What moves in
- `VAULT_STORE_KEY`the at-rest key for everything in the table above.
- `VAULT_STORE_KEY`**split into one key per purpose**, rather than moved. See the status note at the
top: the table above is seven unrelated things, and one key for all of them meant one leak opened all
of them.
- `JWT_SECRET` — a signing key rather than an encryption key, but it has the same properties: must
survive restarts, must never be regenerated silently, and benefits from versioning during a rotation.
Leaving one in a store and one in `.env` would be the scattering this is meant to end.
@@ -208,8 +232,10 @@ wallet table and it cannot be interrupted safely, which argues for something tha
## What this does not change
- Secrets stay in Postgres. This moves the **keys**, not the data.
- `crypto.ts`'s interface stays: `encryptSecret` / `decryptSecret`. Only where the key comes from
changes, so no caller is touched.
- ~~`crypto.ts`'s interface stays~~ — **it did not.** Per-purpose keys mean the purpose has to be named
at the call site, so it is `encryptSecret('headscale', plaintext)` now and all seven query modules
were touched. That was the cost of the split, and it is worth stating plainly because this line
originally promised the opposite.
- The owner passphrase on wallet seeds is untouched and stays out of every store. Two independent
secrets is the property that makes a stolen `.env` insufficient, and it survives this design.
+1 -1
View File
@@ -5,7 +5,7 @@ does and does not protect against.
Authoritative for the crypto design. The code is `src/servers/sidecar/wallet/keys.ts` (sealing,
derivation, unlock sessions), `src/databases/officer_db/src/crypto.ts` (storage encryption) and
`src/databases/officer_db/src/queries/wallet.ts` (where the two meet).
`src/databases/officer_db/src/wallet/queries.ts` (where the two meet).
## The requirement
+35 -9
View File
@@ -7,27 +7,53 @@ agent sessions start.
Three directories sit there, and knowing which one a change belongs in is most of the job:
```
officer/
$OFFICER_ROOT/
├── platform/ the application — a git repo
├── capabilities/ what the agent can do — a separate git repo
── data/ runtime state — NOT version controlled
── data/ runtime state — NOT version controlled
├── dockers/ containers the app store provisioned
└── secrets/ the key store — 0600, and NOT in your data backup
```
None of those paths is configured. `src/servers/data-path.ts` derives the root as
`resolve(process.cwd(), '..')` and hangs the rest off it, which is why the pm2 `cwd` pin matters and
why `assertInstallLayout` refuses to boot from the wrong directory.
Officer is a self-hosted platform: an AI agent, a terminal, a file browser, a code editor, email, a
bitcoin wallet, a remote desktop and dashboards, behind one web app. **It is built around one owner**
— user id 1, role `Super Admin`, who bypasses every permission check — and since 2026-08-07 also
admits **additional accounts holding a strict subset of it**, governed by per-role capability grants.
So "which user" has two answers depending on the surface. For the **app** capabilities (gitea, music,
photos, email, calendar…) it is a real question with a real answer. For anything that executes code or
touches the disk — terminal, chat, tasks, files, desktop, browser — it is still always the owner:
those are `kind: 'execution'` in `platform/src/servers/capabilities/registry.ts` and can never be
granted, because they run as the owner's OS user in the owner's home.
So "which user" has three answers depending on the surface. For the **app** capabilities (gitea,
music, photos, email, calendar…) it is a real question with a real answer. For **confined** ones —
terminal, chat, files — it is also real, because the account has its own Linux user and the kernel
enforces the boundary; a grant there means nothing without that user, and `authorize.ts` drops it.
For **execution** — tasks, items, desktop, browser — it is still always the owner, and those can
never be granted at any level.
That is five kinds, not four: `core`, `app`, `confined`, `execution`, `admin`. Terminal, chat and
files moved from `execution` to `confined` on 2026-08-11 with per-user Linux accounts.
This paragraph said "there is no tenancy, no roles, no other users" until 2026-08-07. Four roles exist
and five non-owner accounts are live; treat the capability registry as the source of truth over any
prose, here or elsewhere.
## What is switched off (2026-08-13)
A core install runs **six** pm2 processes: `officer`, `officer-anthropic-proxy`,
`officer-claude-code`, `officer-opencode`, `officer-pty`, `officer-headscale`. Everything else is a
plugin, and every plugin router is commented out in `hono.ts` with its capability's `api` claim
commented beside it — they must move together or `assertCapabilityTotality` refuses to boot.
The implementations are all still on disk. Nothing was deleted; the mounts were switched off pending
extraction into the plugin system.
Also gone: the four ecosystem files (generated now, at setup, and gitignored), origin validation,
`OFFICER_OS_USERS` (per-user Linux accounts are unconditional), and the Task Logs feature.
`.env` holds three values — `PORT`, `PUBLIC_URL`, `POSTGRES_URL`. Every key lives in
`$OFFICER_ROOT/secrets/officer-keys.db`, one per purpose. See `docs/secret-store.md`.
`platform/` and `capabilities/` each have their own `CLAUDE.md` with detail. This file is the layer
above them: where things live, how to change them safely, and the things that are true of the running
system but written down nowhere else.
@@ -67,13 +93,13 @@ Commit messages: simple lowercase, no prefixes, explaining *why*.
## Running and checking your work
The server runs under pm2 as `officer`, plus sidecars (`officer-anthropic-proxy`, `officer-agent`,
The server runs under pm2 as `officer`, plus sidecars (`officer-anthropic-proxy`, `officer-claude-code`,
`officer-opencode`, `officer-email`, `officer-pty`, `officer-vnc`, `officer-music`, `officer-vault`,
`officer-slskd`, `officer-headscale`, `officer-transmission`, `officer-invoiceshelf`, `officer-wallet`).
`pm2 list` shows them; `pm2 logs officer` follows.
Two of those names are worth knowing apart: **`officer-anthropic-proxy` holds the Anthropic credential
and proxies API traffic; `officer-agent` is the process that actually runs `claude`.**
and proxies API traffic; `officer-claude-code` is the process that actually runs `claude`.**
**Which process to restart.** A change under `src/servers/sidecar/<name>/` needs that sidecar restarted;
a change anywhere else needs `officer`. Both, if you changed the wire between them. Restarting `officer`
+3 -3
View File
@@ -135,7 +135,7 @@ and the rename sequence leaves `workspaces` with no zombie.
- [x] **`ws-terminals-{id}: null` on a live dashboard is a 500.** Same file, `:61-66` — the
`ws-layout-*` branch has a `value === null``deleteDashboard` case (`:42`); the terminals
branches do not. A null falls to the UPDATE branch and sets a `NOT NULL` column
(`databases/officer_db/src/queries/dashboards.ts:70`) → 23502.
(`databases/officer_db/src/dashboards/queries.ts:70`) → 23502.
**Resolved.** A null on either terminals branch is now a no-op: it means "forget this key", and it
only ever arrives paired with `ws-layout-{id}: null` on a rename, by which point the row is gone.
@@ -202,7 +202,7 @@ these.
> which uuid ids would not.
- [ ] **`dashboards.id` is a global primary key but ids are `slugify(name)`.**
`databases/officer_db/src/schema/dashboards.ts` declares `id: text('id').primaryKey()`. Live:
`databases/officer_db/src/dashboards/schema.ts` declares `id: text('id').primaryKey()`. Live:
`"dashboards_pkey" PRIMARY KEY, btree (id)` plus a redundant
`"uq_dashboards_user_id" UNIQUE, btree (user_id, id)` — evidence per-user ids were intended and
half-built. Ids come from `DashboardPreview.tsx:300` (`slugify(trimmed) || generateSlug()`) and the
@@ -213,7 +213,7 @@ these.
(see `databases/CLAUDE.md` → "Composite keys") — harmless churn, but read the plan.
- [x] **`upsertDashboard`'s UPDATE has no `userId` predicate.**
`databases/officer_db/src/queries/dashboards.ts:73` —
`databases/officer_db/src/dashboards/queries.ts:73` —
`db.update(dashboards).set(set).where(eq(dashboards.id, id))`. The `existing` lookup above it _is_
scoped, so it cannot reach another user's row today, but it is a non-transactional read-then-write.
**It becomes a live cross-user overwrite the moment the PK above is made composite.**
-157
View File
@@ -1,157 +0,0 @@
module.exports = {
apps: [
{
name: 'officer',
script: 'bun',
args: 'start',
watch: false,
},
// The Anthropic credential proxy. Despite the old name (`officer-claude`) this process does NOT
// run agents — it holds the proxy secret and forwards to api.anthropic.com. The process that runs
// agents is `officer-agent` below.
{
name: 'officer-anthropic-proxy',
script: 'bun',
args: 'run src/servers/sidecar/claude/index.ts',
watch: false,
},
// The process that actually runs `claude`. It used to be spawned on demand by the main server,
// which made every agent session a grandchild of `officer` and killed it on every restart. As a PM2
// peer it survives them. It resolves the owner from the database and the proxy secret from the
// proxy's state file, so it needs nothing from `officer` in order to start.
{
name: 'officer-agent',
script: 'bun',
args: 'run src/servers/sidecar/claude/user-instance.ts',
watch: false,
},
{
name: 'officer-opencode',
script: 'bun',
args: 'run src/servers/sidecar/opencode/index.ts',
watch: false,
},
{
name: 'officer-email',
script: 'bun',
args: 'run src/servers/sidecar/email/index.ts',
watch: false,
},
// The only sidecar run by `node` rather than `bun`, and the only one that is not TypeScript: node-pty
// is a native addon. It also does not use sidecar/connect.ts, and carries its own copy of the
// reconnect loop.
{
name: 'officer-pty',
script: 'node',
args: 'src/servers/sidecar/pty/index.mjs',
watch: false,
},
{
name: 'officer-vnc',
script: 'bun',
args: 'run src/servers/sidecar/vnc/index.ts',
watch: false,
},
{
name: 'officer-music',
script: 'bun',
args: 'run src/servers/sidecar/music/index.ts',
watch: false,
},
{
name: 'officer-vault',
script: 'bun',
args: 'run src/servers/sidecar/vault/index.ts',
watch: false,
},
{
name: 'officer-slskd',
script: 'bun',
args: 'run src/servers/sidecar/slskd/index.ts',
watch: false,
},
{
name: 'officer-headscale',
script: 'bun',
args: 'run src/servers/sidecar/headscale/index.ts',
watch: false,
},
{
name: 'officer-transmission',
script: 'bun',
args: 'run src/servers/sidecar/transmission/index.ts',
watch: false,
},
// The books. Wraps a self-hosted InvoiceShelf. Instances, their Sanctum tokens and the company each one
// is pinned to are set by the owner from /invoices/settings and stored encrypted in
// `invoiceshelf_accounts` — read here, never from the environment, because Bun auto-loads `.env` into
// every process in this directory and `officer` would hold the token too.
{
name: 'officer-invoiceshelf',
script: 'bun',
args: 'run src/servers/sidecar/invoiceshelf/index.ts',
watch: false,
},
// Video. Wraps a self-hosted Jellyfin. Servers, and the access token each one is signed in with, are set
// by the owner from /jellyfin and stored encrypted in `jellyfin_servers` — read here, never from the
// environment. Video only: Officer's own player owns audio.
{
name: 'officer-jellyfin',
script: 'bun',
args: 'run src/servers/sidecar/jellyfin/index.ts',
watch: false,
},
// Notes. Wraps a self-hosted Memos. The instance URL and its personal access token are set by the
// owner from the UI and stored in `service_connections` — read here, never from the environment.
{
name: 'officer-memos',
script: 'bun',
args: 'run src/servers/sidecar/memos/index.ts',
watch: false,
},
// Code hosting. Wraps a self-hosted Gitea. The instance URL and its personal access token are set by
// the owner from /gitea and stored in `service_connections` — read here, never from the environment.
{
name: 'officer-gitea',
script: 'bun',
args: 'run src/servers/sidecar/gitea/index.ts',
watch: false,
},
// Calendar and contacts. Supervises Radicale (CalDAV/CardDAV) on a loopback port and owns the
// collections under DATA_PATH/dav. Two doors: /dav for phones (DAVx5, iOS, Thunderbird — HTTP Basic
// against a scoped app password) and /api/caldav for Officer's own UI. The protocol is Radicale's;
// the platform authenticates and forwards. See docs/nextcloud-replacement.md.
{
name: 'officer-caldav',
script: 'bun',
args: 'run src/servers/sidecar/caldav/index.ts',
watch: false,
},
// The photo library. Wraps a self-hosted Immich. The instance and its key are set by the owner from
// /photos/settings and stored encrypted in `photos_config` — read here, never from the environment,
// because Bun auto-loads `.env` into every process in this directory and `officer` would hold it too.
{
name: 'officer-photos',
script: 'bun',
args: 'run src/servers/sidecar/photos/index.ts',
watch: false,
},
// The bitcoin wallet. Holds seed material (sealed under an owner passphrase) and node credentials, so
// it is the one sidecar whose restart has a security-relevant side effect: every wallet relocks.
// The one place anything leaves this machine to tell the owner something: push (APNs + FCM) and the
// Discord webhook, behind one interface. A sidecar rather than platform code because the producers
// are spread across sidecars, and a platform-owned notifier would make every one of them call back in.
{
name: 'officer-notify',
script: 'bun',
args: 'run src/servers/sidecar/notify/index.ts',
watch: false,
},
{
name: 'officer-wallet',
script: 'bun',
args: 'run src/servers/sidecar/wallet/index.ts',
watch: false,
},
],
};
@@ -1,55 +0,0 @@
// Linux light profile — the platform without the self-hosted estate around it.
//
// For a machine that should run the file browser, the terminal and Claude/opencode chat, and nothing
// else. Paired with `OFFICER_PROFILE=light bash scripts/setup/setup.sh`, which installs only what these
// processes need: node, bun, ffmpeg, Postgres, pm2 and the two agent CLIs.
//
// This is a subset of ecosystem.config.cjs, not a copy of it — see ecosystem.profile.cjs for why, and
// for the two checks that make a drifted profile fail loudly instead of silently starting less than it
// claims. To change what runs, edit INCLUDE. To change HOW something runs, edit ecosystem.config.cjs
// and every profile follows.
//
// The app itself is unchanged: every API route stays mounted, so features whose sidecars are absent
// report themselves unavailable rather than disappearing. A profile decides which processes start, not
// which code ships.
//
// Start with: pm2 startOrRestart ecosystem.light.config.cjs
const { defineProfile } = require('./ecosystem.profile.cjs');
module.exports = defineProfile({
file: 'ecosystem.light.config.cjs',
include: [
'officer', // the app: SPA, /api, websockets
'officer-anthropic-proxy', // holds the Anthropic credential, forwards upstream
'officer-agent', // spawns `claude` — chat is dead without it
'officer-opencode', // the alternative agent
'officer-pty', // the terminal
],
// Excluded by CHOICE rather than by platform limits — every one of these would run on a Linux host.
// A light install simply is not running the thing behind it.
excluded: {
// Was in the baseline until 2026-08-11, on the reasoning that it fronts a REMOTE instance and so needs
// nothing installed locally. True, and beside the point: a baseline process appears in the Permissions
// screen and the dock whether or not anyone has given it a URL, so a fresh server offered to grant Gitea
// access to an instance that did not exist. It is installable now — `existing` mode, URL and token — which
// makes "is Gitea here" one question with one answer instead of two that disagree.
'officer-gitea': 'fronts a remote instance; installed from the app store with its URL and token',
'officer-vnc': 'no desktop to mirror on a light install',
'officer-email': 'needs the mbsync/IMAP stack the light profile does not install',
'officer-music': 'the ffprobe indexer works, but a full library index is not a light-install concern',
'officer-vault': 'reverse-proxies a self-hosted Vaultwarden container',
'officer-slskd': 'supervises the slskd daemon',
'officer-headscale': 'fronts a headscale server',
'officer-transmission': 'fronts a transmission daemon',
'officer-invoiceshelf': 'fronts an InvoiceShelf container',
'officer-jellyfin': 'fronts a Jellyfin container',
'officer-memos': 'needs an owner-configured Memos instance URL and token',
'officer-photos': 'needs an owner-configured Immich instance URL and API key',
'officer-caldav': 'supervises Radicale, which the light profile does not install',
'officer-notify': 'its producers are the queue and the email/agent sidecars; nothing to notify about',
'officer-wallet': 'holds seed and node credentials',
},
});
@@ -1,68 +0,0 @@
// macOS light profile — the same process set as the Linux light profile, on a laptop.
//
// Paired with scripts/setup/setup_mac_light.sh. Runs the file browser, the terminal and Claude/opencode
// chat; nothing else.
//
// This is a subset of ecosystem.config.cjs, not a copy of it. That distinction is here because of this
// file specifically: written on 2026-07-28 as a hand-copied process list, it was broken within days by
// two changes it could not see. It ran `officer-claude` against the Anthropic proxy's entry point
// while the process that actually spawns `claude` was never started, and it pointed at a pty sidecar
// that had moved. Both failures were silent — the processes simply did not come up. See
// ecosystem.profile.cjs for the checks that now make that loud.
//
// WHY THIS IS SEPARATE FROM ecosystem.light.config.cjs, given both currently run the same five apps:
// the exclusions mean different things. On macOS officer-vnc cannot run — there is no Xorg to mirror.
// On a Linux light install it could run perfectly well; you have chosen not to. Those diverge as soon
// as one profile gains something the other cannot have, and collapsing them would lose the reason.
//
// Start with: pm2 startOrRestart ecosystem.mac.light.config.cjs
const { defineProfile } = require('./ecosystem.profile.cjs');
module.exports = defineProfile({
file: 'ecosystem.mac.light.config.cjs',
include: [
'officer', // the app: SPA, /api, websockets
'officer-anthropic-proxy', // holds the Anthropic credential, forwards to api.anthropic.com
// Spawns `claude`. Reads the proxy secret from disk, so it needs no ordering against the proxy
// above: if the secret is not written yet it warns and re-reads before the next spawn.
'officer-agent',
'officer-opencode', // the alternative agent
// The terminal. Runs under node rather than bun — node-pty binds a native addon built against
// node's ABI. That detail lives in ecosystem.config.cjs, not here.
'officer-pty',
],
excluded: {
// Cannot run on macOS at all.
'officer-vnc': 'mirrors an Xorg display with x11vnc; macOS has no Xorg',
// Left the baseline on 2026-08-11, on both light profiles together. It genuinely needs nothing installed
// locally — it points at a remote instance over the network — but a baseline process shows up in the dock
// and the Permissions screen whether or not a URL was ever given, so "is Gitea here" had two answers. It
// is an app-store install now: `existing` mode, URL and token, same as any other remote service.
'officer-gitea': 'fronts a remote instance; installed from the app store with its URL and token',
// Would run, but needs something setup_mac_light.sh deliberately does not install.
'officer-email': 'needs the mbsync/IMAP stack setup_mac_light.sh does not install',
'officer-caldav': 'supervises Radicale, which setup_mac_light.sh does not install',
'officer-music': 'the ffprobe indexer works, but a full ~/Music index is expensive to start by default',
// Fronts a container or daemon a laptop is not running.
'officer-vault': 'reverse-proxies a self-hosted Vaultwarden container',
'officer-slskd': 'supervises the slskd daemon',
'officer-headscale': 'fronts a headscale server',
'officer-transmission': 'fronts a transmission daemon',
'officer-invoiceshelf': 'fronts an InvoiceShelf container',
'officer-jellyfin': 'fronts a Jellyfin container',
// Needs an owner-configured external service.
'officer-memos': 'needs an owner-configured Memos instance URL and token',
'officer-photos': 'needs an owner-configured Immich instance URL and API key',
// Deliberate, for what it holds or who feeds it.
'officer-notify': 'its producers are the queue and the email/agent sidecars; nothing to notify about',
'officer-wallet': 'holds seed and node credentials; not on a laptop',
},
});
-85
View File
@@ -1,85 +0,0 @@
// Shared machinery for the pm2 install profiles (ecosystem.light.config.cjs,
// ecosystem.mac.light.config.cjs).
//
// A profile is a SUBSET of ecosystem.config.cjs, declared as names plus reasons. It never restates how
// a process is launched — `script` and `args` are read from the host file at load — because a
// hand-copied process list is exactly what failed here: the macOS list was written on 2026-07-28 and
// within days was starting a sidecar that had been split in two and pointing at a pty entry point that
// had moved. Neither failure said anything; the processes simply did not come up.
//
// So the rule is: ecosystem.config.cjs is the only place a launch command is written down, and a
// profile only decides which of them to run.
//
// Two consistency checks, both of which turn a silent breakage into a loud one at load:
// 1. a name the profile INCLUDES that the host no longer defines — the app was renamed or removed
// 2. an app the host defines that the profile neither includes nor excludes — a new sidecar, which
// must be classified deliberately rather than defaulting to absent because nobody noticed
//
// The second is the one that matters over time. Without it, every sidecar added to the host silently
// stays out of every profile, and the profiles quietly stop meaning what their comments claim.
/**
* @param {object} spec
* @param {string} spec.file this profile's filename, for error messages
* @param {string[]} spec.include app names to run, in start order
* @param {Record<string,string>} spec.excluded app name → why it is not in this profile
*/
// The directory holding the platform's package.json, found by walking up from this file. Independent of
// where in the tree this config is kept, and of where pm2 was invoked from.
function repoRoot() {
const { existsSync, readFileSync } = require('node:fs');
const { dirname, join } = require('node:path');
let dir = __dirname;
for (;;) {
const manifest = join(dir, 'package.json');
if (existsSync(manifest)) {
try {
if (JSON.parse(readFileSync(manifest, 'utf8')).name === 'officer') return dir;
} catch {
// Unparseable is not ours; keep walking.
}
}
const up = dirname(dir);
if (up === dir) throw new Error("ecosystem.profile.cjs: could not find the platform's package.json above " + __dirname);
dir = up;
}
}
function defineProfile({ file, include, excluded }) {
const full = require('./ecosystem.config.cjs');
const byName = new Map(full.apps.map((app) => [app.name, app]));
const missing = include.filter((name) => !byName.has(name));
if (missing.length) {
throw new Error(
`${file}: ${missing.join(', ')} not found in ecosystem.config.cjs — the app was renamed or ` +
`removed. Update this profile's include list.`,
);
}
const unclassified = full.apps
.map((app) => app.name)
.filter((name) => !include.includes(name) && !(name in excluded));
if (unclassified.length) {
throw new Error(
`${file}: ${unclassified.join(', ')} is in ecosystem.config.cjs but neither included nor ` +
`excluded here. Add it to the include list, or to the excluded map with a reason.`,
);
}
// `cwd` is pinned because Bun auto-loads .env from the working directory (and the pty sidecar does
// `import 'dotenv/config'`). Without it, starting pm2 from anywhere but the repo root silently falls
// back to the default PORT with no POSTGRES_URL.
//
// It also decides where the install is. src/servers/data-path.ts derives OFFICER_ROOT as the PARENT of
// the working directory, and data/, capabilities/ and dockers/ hang off that — so a wrong cwd does not
// fail, it relocates the whole install. `assertInstallLayout` is the boot check that catches it.
//
// This was `__dirname`, with a comment asserting "__dirname is the repo root — this file sits beside
// ecosystem.config.cjs". That stopped being true the moment these files were moved into
// ecosystem-files/, and nothing said so. Found by walking up to the package.json instead, which is
// true wherever this file ends up living.
return { apps: include.map((name) => ({ ...byName.get(name), cwd: repoRoot() })) };
}
module.exports = { defineProfile };
+1 -1
View File
@@ -25,7 +25,7 @@
"format": "{ git diff --name-only HEAD -- 'src/**/*.ts' 'src/**/*.tsx'; git ls-files --others --exclude-standard -- 'src/**/*.ts' 'src/**/*.tsx'; } | xargs -r prettier --write",
"format:all": "prettier --write \"src/**/*.{ts,tsx}\"",
"format:check": "prettier --check \"src/**/*.{ts,tsx}\"",
"setup": "bash scripts/setup/officer-setup.sh"
"setup": "bash scripts/install.sh"
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.41",
+194
View File
@@ -0,0 +1,194 @@
# Extracting a feature into a plugin
The runbook, written the day offscale became the first one. Follow it for music, then for the rest.
**Read first, in this order:**
1. `plugins/offscale/PLUGIN.md` — every decision and why, including the three that reversed
2. `plugins/example/` — the reference implementation, deliberately the smallest real plugin
3. `plugins/offscale/` — the worked example, all four parts
4. `plugins/music/PLUGIN.md` — the MESSY worked example: three pieces that stayed behind, and why each
is a seam rather than a loose end. Read it if your feature has anything the platform also uses.
5. `src/servers/plugins/` — the system itself: `manifest`, `discover`, `mount`, `install`, `ecosystem`, `schema`, `generate`
---
## The rules. These are not preferences
**Every plugin route renders a Workspace with at least one panel.** A plugin contributes `web/panels.ts`
(`appRegistryMetas`, at least one) and `web/layout.ts` (`defaultLayout`); the shell renders
`WorkspaceView` around them. There is no way to export a component — a `web/` directory missing either
file is **refused at discovery, by name**. Non-compliance is unrepresentable, not forbidden.
**Every plugin permission is grantable, per role, at read or write.** No `kind`, no `ownerOnly`, no field
of any sort. The platform's answer is uniform; what a grant _means_ — whose rows a member sees, whether a
resource is shared or per-user — is the plugin's own job, in its own queries.
**Say `permissions`, never the other word.** It already means three things in this codebase.
**The manifest holds only what a directory listing cannot say.** Identity facts and human choices:
`publisher`, `version`, `platform`, `label`, `summary`, `icon`, `color`, `permissions`. Everything
structural is convention — presence is the declaration:
```
manifest.ts required
api/router.ts a backend router, mounted at mountPrefix()
db/schema.ts tables, prefixed <app-name>_
sidecar/index.ts a process (.mjs instead means node)
web/panels.ts panels — REQUIRED with web/
web/layout.ts layout — REQUIRED with web/
```
**A host binary is the one exception, and it goes in the manifest** — the tree cannot say it. Declare
`osDependencies` when your plugin shells out to something: the binary to probe on PATH, why it is needed,
and a package name per package manager. Absent means self-sufficient, which offscale and example are.
Music added the field; see its PLUGIN.md for what it is guarding against.
`appName` is the **directory name**. The sidecar runtime is the **file extension**.
**Nothing may branch on provenance** except `mountPrefix()`. First-party and third-party differing
anywhere else means two systems, and only one gets tested.
**Uninstall never destroys data.** The generated schema barrel follows plugin **directories**, not the
install table — `db:push` drops what it cannot see, so following installs would delete a plugin's tables
on uninstall. Only deleting a plugin's source can lose its data.
---
## The order that worked
1. **Map it first.** Sidecar, api router, db, frontend, and every line of platform wiring that names it.
2. **Move the backend**: `sidecar/``plugins/<name>/sidecar/`, `api/<name>/router.ts`
`plugins/<name>/api/router.ts` (export `router`, not `<name>Router`), `officer_db/src/<name>/*`
`plugins/<name>/db/`.
3. **Rewrite imports.** Platform code becomes `@@/…` (resolves from `plugins/` — verified). Queries take
`officerdb/db` and `officerdb/crypto`. Schema takes `officerdb/auth/schema``users.id` is the one
reference a plugin may make.
4. **Write `manifest.ts`.**
5. **Move the frontend** to `web/`, as `panels.ts` + `layout.ts`. Imports of platform UI become
`officerdev` (the barrel exports `WorkspaceView`, `TerminalView`, `AppRegistryMeta`); `hooks/useClient`
and `helpers/clipboard` stay as they are.
6. **Remove every trace from the platform**, and delete rather than comment out: `hono.ts` mount and
import, the `capabilities/registry.ts` entry, `App.tsx` routes, `Screens/Dashboard/index.tsx`,
`AppRegistry.tsx`, `officerdev/src/index.ts` re-exports, `Dock.tsx` tile, `usePageTitle.ts` rule, and
**both** database barrels (`index.ts` and `schema.ts`).
7. **`bunx tsgo`** until clean. It finds the wiring you missed.
8. **Verify on the live server** — see below.
9. **Commit and push.** Message says what moved, what it found, and what is still open.
---
## Verification — run all of it
```
bun test # 757 pass, 10 pre-existing failures. Any 11th is yours
pm2 restart officer
```
Then through `/plugins`, watching PM2 and the browser at each step:
| Step | Expect |
| ------------------------------- | ----------------------------------------------------------- |
| install | streamed log; schema applied; sidecar online; route mounted |
| the plugin's API | answers |
| the plugin's screen | renders as a Workspace |
| dock | tile appears |
| permissions page | its permission is listed, read/write/none |
| disable | route 404s, sidecar stops, **tables and rows survive** |
| enable | comes back |
| uninstall | route gone, `pm2 list` loses it, **data still there** |
| `bun db:push` while uninstalled | `No changes detected` — data survives |
| install again | identical to the first install |
A normal refresh is enough; the shell is `no-store`. When the log's last line appears, the bundle exists.
---
## Traps, all of which cost real time once
- **Mount before starting the sidecar.** `createSidecarProxy` learns its port from a one-shot
`<name>:server` event and subscribes when the router is first imported — at mount. Start first and the
announcement fires into a void: online process, mounted routes, every request `503`. Already fixed in
`install.ts`; do not reorder it.
- **`src/servers/sidecar/protocol.ts` still declares `<name>:server` per sidecar.** Music will need its
line kept, or the union generalised to `` `${string}:server` `` — which is the better fix and is
pending for the whole protocol.
- **`bunfig.toml` plugins do not reach `Bun.build()`.** Tailwind is passed explicitly in `generate.ts`.
- **The shell output is named for the entrypoint** (`index.gen.html`), and `naming` does not change it.
- **A stale generated file** (`Plugins.gen.tsx`, `plugin-schemas.gen.ts`) will fail the typecheck after a
contract change. Regenerate rather than hand-edit.
- **Delete the feature's `app-store/catalogue.ts` entry, or its screen goes blank.** `capabilityAvailability`
derives from `sidecar_installs`, and a plugin never gets a row there — its install state is
`plugin_installs`. A leftover catalogue entry therefore makes the capability permanently `unavailable`,
which puts its route into `deniedRoutes` and withholds the dock tile, on a server where the plugin is
installed and healthy. This has now bitten twice: headscale (2026-08-14) and nearly music. The note in
`catalogue.ts` is the one to read.
- **Moving a `*.test.ts` into `plugins/` used to stop it running, silently.** `[test] root` was `./src`
until music; it is now `.`. If that ever goes back, every extraction quietly shrinks the suite. Compare
the FILE COUNT across a run, not just pass/fail — that is the only thing that shows it.
- **A manifest is read once per server process.** Discovery does `await import(manifest.ts)`, and the
module cache holds it for the lifetime of the process — so editing a manifest while developing changes
nothing until `pm2 restart officer`. Costs ten minutes the first time, because the plugins page keeps
cheerfully showing the old values. `outdated` cannot notice a version bump without a restart either.
- **A plugin importing platform code is fine (`@@/`); the reverse is not.** If something in `src/` imports
from your feature and cannot move — a widget, a relay — that piece stays, and the boundary goes around
it. Find those before you plan the split; they decide it for you.
---
## Music is done. What it changed about this runbook
Extracted 2026-08-15 and verified live through the whole table above. `plugins/music/PLUGIN.md` is the
record; the parts worth carrying forward are already folded into the rules and traps above.
The one thing that generalises: **map what the PLATFORM still needs from your feature before you plan the
split.** Music's boundary was not chosen — it was dictated by two imports pointing the wrong way (a
dashboard widget reaching for `useMusicPlayer`, a cliamp relay reaching for `getMusicServerWsUrl`), and
both were found by reading the import graph rather than by reasoning about what music "is". Offscale had
none, so it came out whole and made the job look cleaner than it is.
The three pieces music left behind are `officerdev/src/MusicPlayer/`, `src/servers/api/music/router.ts`
and everything cliamp. Each is documented where it sits. **None of them is work waiting for you** — do
not tidy them into a plugin as a warm-up.
### The global-overlay question is answered, and the answer is no
Music was the first feature wanting to render on every route. It does not get to, and neither will the
next one: a shell slot for a plugin-provided component reopens "there is no way to export a component",
which is the rule the whole frontend contract rests on. `MusicPlayerHost` stays in `DashboardLayout`,
gated on its plugin's permission so it switches itself off with the plugin.
Reopen this only for a feature where the overlay is the whole product, and expect to argue for it.
---
## Which one next
No decision has been made. What the tree says, for whoever picks it:
- **`schema.ts` still lists eight commented plugin schemas** — email, notify, dav, photos, jellyfin,
invoiceshelf, soulseek, vault, wallet. Each line names its tables and the file that defines them, which
is exactly what its extraction needs.
- **`hono.ts` still has fifteen commented mounts.** Same list, roughly.
- **Soulseek is the interesting one**, and not because it is easy: `docs/navigation-audit.md` records its
panels making 37 raw upstream calls, which is the mistake the offscale sidecar exists to avoid. Its
extraction is a rewrite wearing a move's clothes. Say so up front rather than discovering it at 2am.
- **Email and wallet both hold credentials**, so they meet `secret-store` and `service_connections` in a
way neither of the first two did. Read `docs/secret-store.md` first.
## Still open, platform-wide. Do not rediscover these
- **Websocket providers** — `server.reload({ routes })` proven, never called. No plugin owns a socket yet;
music would have been the first and cliamp being out of scope is what let it pass.
- **`assertCapabilityTotality` reads the wrong list** — `Object.keys(handlers)` while Bun serves the route
table, and plugin routes are not in `PROTECTED_API_PREFIXES` at all. It belongs in `buildHonoApp()`,
now the single place routes are mounted. Security-adjacent; close it before members reach plugin routes.
The live example is the two cliamp sockets: served in the route table, claimed by no capability, and
invisible to the check. Pinned by a test in `registry.test.ts` so it stays a known fact.
- **Two dock sources** — the app store keeps its own catalogue; one when it is rebuilt on this
- **Offscale's queries scope by caller**, so a granted member sees their own empty list rather than the
owner's. Its own job, not the platform's.
- **`protocol.ts` declares `<name>:server` per sidecar.** `music:server` and `headscale:server` are both
still there for plugins that have left. Generalising the union to `` `${string}:server` `` is the fix.
- **`hasPersonalWrites` reads `c.personal` only**, so a plugin declaring the same thing through
`readOnlyWrites` reports `false`. Nothing renders it, so it is dead on the wire.
+10
View File
@@ -0,0 +1,10 @@
import { createRouter } from '@@/create-router';
// Mounted at `/api/example` — the prefix comes from `mountPrefix()`, which reads the manifest's
// `publisher`. Nothing here knows or cares whether this plugin is first-party.
//
// `createRouter()` rather than a bare `new Hono()`: it carries the platform's context types, so
// `ctx.get('user')` is typed and the middleware above behaves the same as it does for core routes.
export const router = createRouter();
router.get('/ping', (ctx) => ctx.json({ plugin: 'example', ok: true }));
+35
View File
@@ -0,0 +1,35 @@
import type { PluginManifest } from '@@/plugins/manifest';
// The reference plugin. Not a fixture — this is what a plugin author reads first, and it is deliberately
// the smallest thing that is still a real one: a manifest and one route.
//
// Everything structural is convention, so this directory IS the documentation:
//
// manifest.ts you are here — only what a directory listing cannot say
// api/router.ts exports `router`; mounted at /api/example
// db/schema.ts tables, if it had any (every name prefixed `example_`)
// sidecar/index.ts a process, if it needed one (.mjs instead means node)
// web/Router.tsx a frontend, if it had one
//
// `appName` is not declared anywhere: it is the directory name, so the id cannot disagree with where the
// code sits.
export const manifest: PluginManifest = {
publisher: 'officerdev',
version: '1.0.0',
platform: '>=1.0.0',
label: 'Example',
summary: 'The reference plugin — one route, nothing else',
icon: 'Puzzle',
color: '#94a3b8',
// One permission gating the whole surface. `ownerOnly: false` means a role can be granted it — which is
// the interesting case, because it is the one the permission gate actually has to resolve.
permissions: [
{
key: 'example',
label: 'Example',
description: 'The reference plugin',
},
],
};
+24
View File
@@ -0,0 +1,24 @@
// The reference sidecar: a long-lived process PM2 supervises.
//
// A sidecar is a PEER of `officer`, never a child — that is why restarting the platform does not disturb
// it, and it is the property that makes install-without-restart possible on the platform side too.
//
// A real one binds a loopback port and registers over `/api/sidecar/register` so the platform can reach
// it by capability (see `servers/sidecar/connect.ts`). This one does neither, on purpose: it exists to
// prove that a plugin's process is written into the ecosystem file, started, stopped and deleted by the
// installer, and adding a socket here would test Bun rather than that.
const name = 'officer-example';
console.log(`[${name}] started (pid ${process.pid})`);
// Something to see in `pm2 logs officer-example`, and a reason for the process to still be alive.
const beat = setInterval(() => console.log(`[${name}] alive`), 60_000);
const shutdown = (signal: string) => {
console.log(`[${name}] ${signal} — exiting`);
clearInterval(beat);
process.exit(0);
};
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
+23
View File
@@ -0,0 +1,23 @@
import { useParams } from 'react-router';
// The second panel, reading the URL rather than being told by its sibling.
//
// The shell registers `<prefix>` and `<prefix>/:section`, so a plugin's sections are addressable,
// linkable and cmd-clickable — the same convention every core screen follows. Panels read `useParams`
// independently; nothing is passed between them, so they cannot disagree.
export const ExampleDetail = () => {
const { section } = useParams();
return (
<div className="h-full overflow-auto p-6">
<h2 className="text-lg font-semibold text-duck-dark">Detail</h2>
<p className="mt-1 text-sm text-duck-dark/60">
Section from the URL: <code>{section ?? '(none)'}</code>
</p>
<p className="mt-3 text-xs text-duck-dark/40">
Try <code>/example/anything</code> this panel reads it from <code>useParams</code>, with no state passed from
the panel beside it.
</p>
</div>
);
};
+27
View File
@@ -0,0 +1,27 @@
import { useClient } from 'hooks/useClient';
import { useQuery } from '@tanstack/react-query';
// A panel, not a screen. It gets whatever space the layout gives it and knows nothing about routing.
//
// `useClient` comes from the platform's workspace packages, resolved because a plugin lives inside the
// repository — no publishing, no version negotiation. This is the whole plugin↔host API in one line.
export const ExampleOverview = () => {
const client = useClient();
const { data, isLoading } = useQuery({
queryKey: ['example', 'ping'],
queryFn: () => client.get<{ plugin: string; ok: boolean }>('/example/ping'),
});
return (
<div className="h-full overflow-auto p-6">
<h2 className="text-lg font-semibold text-duck-dark">Example</h2>
<p className="mt-1 text-sm text-duck-dark/60">
A panel from <code>plugins/example/web/</code>, rendered by the shell's <code>WorkspaceView</code>.
</p>
<div className="mt-4 rounded-md border border-duck-dark/10 bg-duck-dark/[0.02] p-3 font-mono text-xs">
<div className="mb-1 text-duck-dark/50">GET /api/example/ping</div>
{isLoading ? <span className="text-duck-dark/40"></span> : <span>{JSON.stringify(data)}</span>}
</div>
</div>
);
};
+16
View File
@@ -0,0 +1,16 @@
import type { LayoutNode } from 'officerdev';
// How this plugin's panels are arranged. The shell renders `WorkspaceView` with this as the default and
// persists the user's version per plugin, so this is the starting arrangement rather than a fixed one.
//
// Every `appType` here must be a key from `panels.ts` — `appTypes.allowed` is pinned to them, so a
// mismatch falls back rather than rendering another plugin's panel inside this screen.
export const defaultLayout: LayoutNode = {
type: 'group',
id: 'example-root',
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: 'example-overview', appType: 'example-overview' }, size: 40 },
{ node: { type: 'panel', id: 'example-detail', appType: 'example-detail' }, size: 60 },
],
};
+16
View File
@@ -0,0 +1,16 @@
import { Puzzle, ListTree } from 'lucide-react';
import type { AppRegistryMeta } from 'officerdev';
import { ExampleOverview } from './ExampleOverview';
import { ExampleDetail } from './ExampleDetail';
// The panels this plugin contributes. AT LEAST ONE, or discovery refuses the plugin.
//
// A plugin never renders a screen — the shell renders `WorkspaceView` around these, arranged by
// `layout.ts`. That is what makes "every plugin route is a Workspace" a property of the shape rather than
// a rule someone has to remember.
//
// `availableOnPanel: false` keeps them off the generic panel picker: they belong to this plugin's screen.
export const appRegistryMetas: AppRegistryMeta[] = [
{ key: 'example-overview', name: 'Overview', icon: Puzzle, component: ExampleOverview, availableOnPanel: false },
{ key: 'example-detail', name: 'Detail', icon: ListTree, component: ExampleDetail, availableOnPanel: false },
];
+70 -27
View File
@@ -29,12 +29,12 @@ GET /api/music/stream?path=<home-relative>&token=<jwt>
Byte-range streaming so the player can **seek without downloading the whole file**.
| Case | Status | Headers |
|---|---|---|
| --------------------- | ------ | --------------------------------------------------------------------------------------------- |
| No `Range` | `200` | `Content-Type`, `Content-Length`, `Accept-Ranges: bytes`, `X-Audio-Duration` |
| With `Range: bytes=…` | `206` | `Content-Range`, `Content-Length`, `Accept-Ranges: bytes`, `Content-Type`, `X-Audio-Duration` |
- **`X-Audio-Duration`**: track duration in **seconds** (ffprobe-derived). Read this to set the player's
duration up front — it's the fix for AVPlayer reporting an *indefinite* duration on progressively-streamed
duration up front — it's the fix for AVPlayer reporting an _indefinite_ duration on progressively-streamed
VBR MP3s. No need to scan the file.
- Errors: `400` invalid/missing path · `404` not found · `416` bad range.
@@ -51,13 +51,14 @@ The server maintains a cache tree that **mirrors the library**, one entry per al
this instead of walking + ID3-parsing the library itself.
Each album has a **version stamp `v`** (hash of the album's source files' names/sizes/mtimes + its cover).
`v` changes **iff the album's content changed** → it's the whole basis of the diff: *unchanged `v` ⇒ skip*.
`v` changes **iff the album's content changed** → it's the whole basis of the diff: _unchanged `v` ⇒ skip_.
### 2.1 Manifest — one call, whole library
```
GET /api/music/manifest
```
```jsonc
{
"version": 1,
@@ -66,11 +67,12 @@ GET /api/music/manifest
"Albums/AC-DC/[1980] Back in Black": { "v": "50856380f1ca8f9", "cover": true, "tracks": 10 },
"DJ Sets/Dave Clarke": { "v": "a1b2c3d4e5f6a7b", "cover": false, "tracks": 3 },
"Albums/Metallica/[1989] Live Shit": { "v": "beefbeefbeefbee", "cover": true, "tracks": 0, "videos": 2 },
"Albums/AC-DC": { "v": "c0ffee1234567890", "cover": true, "tracks": 0, "disco": true }
"Albums/AC-DC": { "v": "c0ffee1234567890", "cover": true, "tracks": 0, "disco": true },
// …
}
},
}
```
`404` if the index has never been built (see §3). Entries with **`tracks: 0`** are container folders (e.g. an
**artist** folder). An entry with **`disco: true`** is an artist folder that has a discography — fetch its
grouping via `/discography` (§2.4). **`videos: N`** (optional) counts video files (concerts, clips) that live
@@ -82,7 +84,9 @@ may have any mix of `tracks`, `videos`, and `disco`.
```
GET /api/music/meta?path=<rel>
```
Returns the album's `meta.json`. Sends `ETag: <v>`; a request with `If-None-Match: <v>` returns `304`.
```jsonc
{
"path": "Albums/AC-DC/[1980] Back in Black",
@@ -97,23 +101,25 @@ Returns the album's `meta.json`. Sends `ETag: <v>`; a request with `If-None-Matc
"track": "1",
"year": "1980",
"durationSec": 312,
"lyrics": "lrc" // present if lyrics exist: "lrc" = synced, "txt" = plain (see §2.3.2)
}
"lyrics": "lrc", // present if lyrics exist: "lrc" = synced, "txt" = plain (see §2.3.2)
},
// …
],
"videos": [ // present only for folders that contain video files
"videos": [
// present only for folders that contain video files
{
"file": "1989 - Seattle.mp4", // filename within the folder
"title": "Live Shit: Seattle", // from the container title tag, if any
"durationSec": 8130,
"width": 1280,
"height": 720,
"poster": "posters/1989 - Seattle.mp4.jpg" // present when a poster was generated (see §2.3.1)
}
"poster": "posters/1989 - Seattle.mp4.jpg", // present when a poster was generated (see §2.3.1)
},
// …
]
],
}
```
All track/video fields except `file` are optional (absent when the tag/stream info is missing). `videos` is
omitted entirely when the folder has none.
To stream a track or video: `GET /api/music/stream?path=Music/<rel>/<file>` (byte-range; works for `.mp4`).
@@ -123,6 +129,7 @@ To stream a track or video: `GET /api/music/stream?path=Music/<rel>/<file>` (byt
```
GET /api/music/cover?path=<rel>
```
Compressed JPEG (≤600px on the long edge, ~3080 KB). Sends `ETag: <v>`; `If-None-Match: <v>``304`.
Only meaningful when the manifest entry has `"cover": true`.
@@ -131,6 +138,7 @@ Only meaningful when the manifest entry has `"cover": true`.
```
GET /api/music/poster?path=<rel>&file=<video filename>
```
A compressed frame grab for a video (≤600px, same treatment as covers), taken ~10% into the clip. `file` is
the video's filename within `<rel>` (URL-encode it). Sends `ETag: <v>`; `If-None-Match: <v>``304`; `404`
when the video has no poster. Only request it when that video's `meta.videos[]` entry has a `poster` field.
@@ -140,6 +148,7 @@ when the video has no poster. Only request it when that video's `meta.videos[]`
```
GET /api/music/lyrics?path=<rel>&file=<track filename>
```
Plain-text body of the track's lyrics; the `X-Lyrics-Format` header is `lrc` (synced, `[mm:ss.xx]`-timestamped)
or `txt` (plain). Sends `ETag: <v>`; `If-None-Match: <v>``304`; `404` when the track has no lyrics. Only
request it when that track's `meta.tracks[]` entry has a `lyrics` field (`"lrc"`/`"txt"`).
@@ -156,7 +165,9 @@ type**, so the player can split an artist's album list into sections (Studio, Li
```
GET /api/music/discography?path=<artist rel> e.g. path=Albums/AC-DC
```
Sends `ETag: <v>`; `If-None-Match: <v>``304`.
```jsonc
{
"artist": "Anthrax",
@@ -164,11 +175,12 @@ Sends `ETag: <v>`; `If-None-Match: <v>` → `304`.
"[1984] Fistful Of Metal": "Studio",
"[1985] Armed And Dangerous": "EP",
"[1994] The Island Years": "Live",
"[1991] Attack Of The Killer B's": "Compilation"
"[1991] Attack Of The Killer B's": "Compilation",
// …
}
},
}
```
- Keys are **album folder names** (`[year] title`) — they map 1:1 to the artist's album folders, i.e. the
last path segment of that album's manifest `<rel>`. Group the artist's albums by looking each up here.
- **Types** are a normalized set: `Studio`, `Live`, `Compilation`, `Single`, `EP`, `Soundtrack`, `Remix`,
@@ -193,14 +205,23 @@ GET /api/music/reindex/status → IndexStatus snapshot
```
`IndexStatus`:
```jsonc
{
"running": true,
"startedAt": 1785034701973, "finishedAt": null,
"foldersScanned": 45, "albumsBuilt": 12, "albumsSkipped": 3,
"tracksIndexed": 320, "videosIndexed": 4, "coversSaved": 12, "postersSaved": 4, "lyricsIndexed": 45, "discographies": 3,
"startedAt": 1785034701973,
"finishedAt": null,
"foldersScanned": 45,
"albumsBuilt": 12,
"albumsSkipped": 3,
"tracksIndexed": 320,
"videosIndexed": 4,
"coversSaved": 12,
"postersSaved": 4,
"lyricsIndexed": 45,
"discographies": 3,
"currentPath": "Albums/AC-DC/[1980] Back in Black",
"error": null
"error": null,
}
```
@@ -209,6 +230,7 @@ GET /api/music/reindex/status → IndexStatus snapshot
```
GET /api/music/reindex/stream
```
- **Triggers a build if none is running.** Pass `?trigger=0` to **watch only** (subscribe without starting one).
- Emits `event: progress` (an `IndexStatus`) throttled to ~200 ms, then a single `event: done` (an
`IndexReport`) and **closes** the stream.
@@ -222,9 +244,19 @@ data: {"albums":15,"built":12,"skipped":3,"foldersScanned":45,"tracksIndexed":32
```
`IndexReport` (the `done` payload):
```jsonc
{ "albums": 15, "built": 12, "skipped": 3, "foldersScanned": 45,
"tracksIndexed": 320, "coversSaved": 12, "discographies": 3, "elapsedSec": 37.2, "error": null }
{
"albums": 15,
"built": 12,
"skipped": 3,
"foldersScanned": 45,
"tracksIndexed": 320,
"coversSaved": 12,
"discographies": 3,
"elapsedSec": 37.2,
"error": null,
}
```
> First build of a large library takes a few minutes; re-runs are near-instant (unchanged albums skip via `v`).
@@ -257,7 +289,7 @@ platform straight from Postgres — same `/api/music` prefix and same auth. Keys
supplies; the server never interprets them:
| kind | key |
|---|---|
| -------- | --------------------------------------------------------------------- |
| `track` | home-path — `Music/<rel>/<file>` (also the `/stream` path & queue id) |
| `album` | music-rel — `Albums/AC-DC/[1980] Back in Black` |
| `artist` | music-rel — `Albums/AC-DC` |
@@ -266,7 +298,11 @@ supplies; the server never interprets them:
- **`GET /api/music/favorites`** → grouped keys, newest first:
```json
{ "tracks": ["Music/…/01 Hells Bells.mp3"], "albums": ["Albums/AC-DC/[1980] Back in Black"], "artists": ["Albums/AC-DC"] }
{
"tracks": ["Music/…/01 Hells Bells.mp3"],
"albums": ["Albums/AC-DC/[1980] Back in Black"],
"artists": ["Albums/AC-DC"]
}
```
- **`POST /api/music/favorites`** `{ "kind": "track|album|artist", "key": "…" }``{ ok: true }`. Idempotent
(a repeat add is a no-op).
@@ -281,9 +317,16 @@ launch to offer "resume".
- **`GET /api/music/now-playing`** → the snapshot or `null`:
```json
{ "homePath": "Music/…/01 Hells Bells.mp3", "dir": "Music/Albums/AC-DC/[1980] Back in Black",
"title": "Hells Bells", "artist": "AC/DC", "album": "Back in Black",
"durationSec": 312.5, "positionSec": 140, "updatedAt": "2026-07-27T11:27:54.441Z" }
{
"homePath": "Music/…/01 Hells Bells.mp3",
"dir": "Music/Albums/AC-DC/[1980] Back in Black",
"title": "Hells Bells",
"artist": "AC/DC",
"album": "Back in Black",
"durationSec": 312.5,
"positionSec": 140,
"updatedAt": "2026-07-27T11:27:54.441Z"
}
```
`dir` is the folder to rebuild the album queue from (empty for a cross-album queue → resume the single track).
- **`PUT /api/music/now-playing`** `{ homePath (required), dir?, title?, artist?, album?, durationSec?, positionSec? }`
@@ -300,7 +343,7 @@ Server-side playlists, scoped to the calling user. Items are track **keys** —
put. `404` throughout means "not yours or not there"; the two are deliberately indistinguishable.
| method | path | body | returns |
|---|---|---|---|
| -------- | -------------------------------- | -------------- | ---------------------------------------------------------------- |
| `GET` | `/api/music/playlists` | — | `[{ id, name, count, createdAt, updatedAt }]`, most recent first |
| `POST` | `/api/music/playlists` | `{ name }` | `201` with the row; `409` if the name is taken |
| `GET` | `/api/music/playlists/:id` | — | `{ id, name, items: [key], … }` |
@@ -315,6 +358,6 @@ put. `404` throughout means "not yours or not there"; the two are deliberately i
- **Covers are server-compressed** (≤600px / q5) — sync them as-is; no client-side resizing needed.
- **Durations are exact** (ffprobe) in both `X-Audio-Duration` and `meta.json`'s `durationSec` (seconds).
- **Playback still goes through `/stream`** — the index is metadata + covers only. (Server-managed *offline
audio files* is a separate, later feature.)
- **Playback still goes through `/stream`** — the index is metadata + covers only. (Server-managed _offline
audio files_ is a separate, later feature.)
- **Errors** are plain HTTP: `503` if the music sidecar isn't connected, `502` if it's unreachable.
+247
View File
@@ -0,0 +1,247 @@
# 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 holds `cliamp-ws.ts`, `pulse-audio.ts`, `asoundrc` and
`cliamp-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.ts` stays, 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:
> `useMusicPlayer` and `PlayerTrack` are imported from `officerdev` by
> `src/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`):
```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 `music` but not `files` gets a working player, working favourites, and an **empty
library**, because every listing 403s;
- and `files` is not a grant that can simply be handed over. `authorize.ts` drops a confined grant for an
account with no `osUser`, 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:
1. **The sidecar lists.** Music already walks the library for its index — `GET /music/ls` would put the
listing behind the `music` permission where it belongs, and the plugin stops needing `files` at all.
Most self-contained, and the most work.
2. **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.
3. **Leave it and document it** — a member needs `files` too. 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.
- **`hasPersonalWrites` reads `c.personal` only**, so the permissions API reports `false` for a plugin
that declares the same thing through `readOnlyWrites`. 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.ts` still declares `music:server`** per 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.ts` rather than left to be rediscovered — closing it is the totality work.
+18
View File
@@ -0,0 +1,18 @@
import { musicRouter } from '@@/api/music/router';
// /api/music/* — auth, then forward to officer-music.
//
// ── Why this re-exports the platform's proxy instead of creating its own ──
//
// `src/servers/api/music/router.ts` has to stay behind: `api/cliamp/relay.ts` imports
// `getMusicServerWsUrl` from it to pipe the cliamp player socket to this sidecar, and cliamp is
// deliberately out of scope — it is a second playback path that the platform still owns.
//
// So the proxy already exists, and building a SECOND `createSidecarProxy({ name: 'music' })` here would
// mean two subscribers to the one-shot `music:server` port announcement. Both would work today, and the
// first reconnect where only one of them was listening would produce a 503 nobody could explain. One
// proxy, one subscription, mounted by whoever needs it.
//
// The seam is one file, and it is inert when this plugin is not installed: the router is only reachable
// once `mountPrefix()` puts it under `/api/music`, which only happens for an installed, enabled plugin.
export const router = musicRouter;
@@ -1,6 +1,6 @@
import { eq, and, desc, asc, sql } from 'drizzle-orm';
import { db } from '../db';
import { musicFavorites, musicNowPlaying, musicPlaylists, musicPlaylistItems } from '../schema';
import { db } from 'officerdb/db';
import { musicFavorites, musicNowPlaying, musicPlaylists, musicPlaylistItems } from './schema';
export type FavoriteKind = 'track' | 'album' | 'artist';
export type GroupedFavorites = { tracks: string[]; albums: string[]; artists: string[] };
@@ -1,5 +1,5 @@
import { pgTable, serial, integer, text, real, timestamp, index, primaryKey, uniqueIndex } from 'drizzle-orm/pg-core';
import { users } from './auth';
import { users } from 'officerdb/auth/schema';
// Per-user music favorites. `key` is an opaque path the app supplies and the server never interprets:
// track → homePath "Music/<rel>/<file>" (also the /stream path + RNTP queue id)
+106
View File
@@ -0,0 +1,106 @@
import type { PluginManifest } from '@@/plugins/manifest';
// Music — the library, the player, and the phone and tablet apps that stream from it.
//
// The second plugin extracted from the platform, on 2026-08-15. Bigger than offscale and, unlike it, not
// a clean cut: three pieces stay behind deliberately. Each is a documented seam rather than a loose end,
// and each is recorded in ./PLUGIN.md with what would have to change to close it.
//
// api/router.ts re-exports the platform's music proxy — see that file for why it is not a new one
// sidecar/ the whole /api/music contract: indexing, streaming, per-user state
// db/ music_favorites, _playlists, _playlist_items, _now_playing
// web/ the library panels; the shell renders the Workspace
//
// ── What stayed in the platform, and why ──
//
// 1. cliamp (`/api/cliamp/ws`, `/api/cliamp/audio/ws`, `sidecar/music/cliamp-ws.ts`, `pulse-audio.ts`,
// `asoundrc`). A second playback path — the `cliamp` TUI run on the server with its terminal and its
// PulseAudio null sink piped to the browser. Already inert (the routes upgrade into commented-out
// handlers) and out of scope by the owner's decision. This sidecar still serves those sockets, so it
// imports both modules from `@@/sidecar/music/`.
//
// 2. The dashboard widget (`src/workspaces/widgets/MusicPlayer/`). Plugins cannot contribute widgets and
// the mechanism was not worth inventing for one.
//
// 3. The global player overlay (`officerdev/src/MusicPlayer/`, mounted by `DashboardLayout`). This was
// the one open judgement call and it is decided: THE PLAYER STAYS IN THE PLATFORM. Two reasons, and
// the second is the one that settles it.
//
// - Moving it needs a shell slot that renders a plugin-provided component on every route. That 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. Reopening it for one plugin is a bad trade.
// - It would not even work. The widget above imports `useMusicPlayer` and `PlayerTrack` from
// `officerdev`, and the platform cannot import from a plugin — so the player STATE stays whatever
// is decided about the UI. Splitting the engine from the state it drives would leave the same seam
// in a worse place, and two copies of that state would mean two engines.
//
// The overlay gates on `can('music')`, which resolves against the permission below — registered at
// install and gone at uninstall. So the seam switches itself off with the plugin, with no code path
// that knows why.
//
// ── Host dependencies ──
//
// Music is the plugin that made `osDependencies` exist. Offscale was self-sufficient, so until this one
// there was nothing to declare and no reason to build the field — see ./PLUGIN.md.
export const manifest: PluginManifest = {
publisher: 'officerdev',
version: '1.0.0',
platform: '>=1.0.0',
label: 'Music',
summary: 'The music library — browse, play, favourites and playlists',
icon: 'Music',
color: '#22c55e',
// One permission gating the whole surface, grantable per role at read or write like every other.
//
// The key is `music` and that is not incidental: it is the key the platform's own registry used until
// this extraction, so every existing `role_capabilities` grant keeps meaning what it meant, and the
// overlay's `can('music')` keeps resolving. Renaming it would have been a silent data change.
//
// `[open]` What a member's grant MEANS here is this plugin's own job and is not finished. Favourites,
// playlists and now-playing are already per-caller — the sidecar scopes every one of them by the
// `X-Officer-User` header the proxy injects — while the library itself is one shared index for the
// household. So "whose row is this" already has a real, non-uniform answer, which is why the platform's
// side of it is a uniform read/write and nothing more. Designing the rest belongs in these queries.
permissions: [
{
key: 'music',
label: 'Music',
description: 'The music library, playback, and your own favourites and playlists',
// These are the `personal` paths from the registry entry this replaces, carried across verbatim.
//
// They are not read-only — they are genuine writes to the CALLER'S own data, which is what made
// them safe at read level. The manifest deliberately has no `personal` field, and adding one would
// be designing the per-user visibility model that is explicitly not this extraction's work. It
// costs nothing to go without: `isRequestAllowedAtLevel` concatenates `personal` and
// `readOnlyWrites` into a single allow-list, so the two are the same mechanism under two names and
// a read grant permits exactly the same four paths it permitted yesterday.
//
// `/queue` is here because it was there. No such route exists, in the sidecar or anywhere else.
readOnlyWrites: ['/favorites', '/now-playing', '/playlists', '/queue'],
},
],
// Both come from one package everywhere, which is luck rather than a rule — hence a name per manager
// rather than one canonical name. `packages.sh` records why that indirection was rejected.
//
// They are declared SEPARATELY even so, because the platform probes binaries and these two fail
// differently. Losing `ffprobe` is the quiet one: 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 reports success. Losing `ffmpeg` costs cover art and video poster frames, which is at
// least visible. Naming both means the owner is told which of the two they are missing.
osDependencies: [
{
binary: 'ffprobe',
reason: 'Reads tags, duration and embedded lyrics. Without it every track indexes as a bare filename.',
packages: { apt: 'ffmpeg', pacman: 'ffmpeg', dnf: 'ffmpeg', brew: 'ffmpeg' },
},
{
binary: 'ffmpeg',
reason: 'Compresses cover art for phones and grabs poster frames from videos.',
packages: { apt: 'ffmpeg', pacman: 'ffmpeg', dnf: 'ffmpeg', brew: 'ffmpeg' },
},
],
};
@@ -1,10 +1,10 @@
import { mkdirSync, writeFileSync } from 'node:fs';
import { join, basename } from 'node:path';
import type { SidecarCommand, SidecarEvent } from '../protocol';
import { createSidecarConnector } from '../connect';
import type { SidecarCommand, SidecarEvent } from '@@/sidecar/protocol';
import { createSidecarConnector } from '@@/sidecar/connect';
import { streamAudioFile } from './stream-audio';
import { cliampUpgradeData, musicWebsocket } from './cliamp-ws';
import { ensurePulseAudio } from './pulse-audio';
import { cliampUpgradeData, musicWebsocket } from '@@/sidecar/music/cliamp-ws';
import { ensurePulseAudio } from '@@/sidecar/music/pulse-audio';
import { startNightlyReindex, stopNightlyReindex } from './nightly-reindex';
import {
reindexNow,
@@ -37,10 +37,9 @@ import {
addPlaylistItems,
setPlaylistItems,
type FavoriteKind,
} from 'officerdb';
import { DATA_PATH } from '../../data-path';
import { API_URL } from '../../officer-url.mjs';
} from '../db/queries';
import { DATA_PATH } from '@@/data-path';
import { API_URL } from '@@/officer-url.mjs';
// ── Per-user state validation ──
// The authenticated user id arrives in X-Officer-User (the platform proxy injects it after auth; we're
@@ -115,7 +114,6 @@ const asKeys = (v: unknown): string[] | null =>
// `v` = per-album version stamp; unchanged `v` ⇒ nothing changed ⇒ the phone can skip re-downloading.
// ─────────────────────────────────────────────────────────────────────────────────────────────────
// ── Audio-streaming HTTP server ──
/** Grab an ephemeral free port by briefly binding one and releasing it. */
@@ -20,7 +20,7 @@ import { homedir } from 'node:os';
// name+size+mtime, cover size+mtime). It drives BOTH incremental build (skip unchanged albums) and the
// phone's resync diff (fetch only changed `v`s).
import { DATA_PATH } from '../../data-path';
import { DATA_PATH } from '@@/data-path';
const HOME = homedir();
export const MUSIC_ROOT = join(HOME, 'Music');
@@ -678,7 +678,9 @@ function logManifestDelta(prev: Manifest, next: Manifest): void {
// A cache-format upgrade rebuilds every album by definition, so the delta is expected and says
// nothing about drift. Label it rather than let it read as 6k albums of rot.
if (prev.version !== next.version) {
console.log(`[music] full reindex: cache format v${prev.version} → v${next.version}, delta below is the upgrade itself`);
console.log(
`[music] full reindex: cache format v${prev.version} → v${next.version}, delta below is the upgrade itself`,
);
}
const { added, removed, changed } = diffManifest(prev, next);
@@ -688,7 +690,9 @@ function logManifestDelta(prev: Manifest, next: Manifest): void {
return;
}
console.log(`[music] full reindex delta: +${added.length} added, -${removed.length} removed, ~${changed.length} changed`);
console.log(
`[music] full reindex delta: +${added.length} added, -${removed.length} removed, ~${changed.length} changed`,
);
const sample = (label: string, rels: string[]) => {
for (const rel of rels.slice(0, 5)) console.log(`[music] ${label} ${rel || '.'}`);
if (rels.length > 5) console.log(`[music] ${label} …and ${rels.length - 5} more`);
@@ -21,7 +21,9 @@ export function startNightlyReindex(): void {
const schedule = () => {
const ms = msUntilNextHour(REINDEX_HOUR);
const at = new Date(Date.now() + ms);
console.log(`[music] nightly full reindex scheduled for ${at.toLocaleString()} (in ${(ms / 3_600_000).toFixed(1)}h)`);
console.log(
`[music] nightly full reindex scheduled for ${at.toLocaleString()} (in ${(ms / 3_600_000).toFixed(1)}h)`,
);
timer = setTimeout(async () => {
console.log('[music] nightly full reindex starting');
try {
@@ -29,7 +29,16 @@ async function probeDuration(absPath: string, mtimeMs: number): Promise<number |
if (cached !== undefined) return cached;
try {
const proc = Bun.spawn(
['ffprobe', '-v', 'error', '-show_entries', 'format=duration', '-of', 'default=noprint_wrappers=1:nokey=1', absPath],
[
'ffprobe',
'-v',
'error',
'-show_entries',
'format=duration',
'-of',
'default=noprint_wrappers=1:nokey=1',
absPath,
],
{ stdout: 'pipe', stderr: 'ignore' },
);
const out = (await new Response(proc.stdout).text()).trim();
@@ -88,7 +97,11 @@ export async function streamAudioFile(relPath: string, rangeHeader: string | nul
}
return new Response(file.slice(start, end + 1), {
status: 206,
headers: { ...baseHeaders, 'Content-Range': `bytes ${start}-${end}/${total}`, 'Content-Length': String(end - start + 1) },
headers: {
...baseHeaders,
'Content-Range': `bytes ${start}-${end}/${total}`,
'Content-Length': String(end - start + 1),
},
});
}
}
@@ -3,9 +3,9 @@ import { Link, useNavigate } from 'react-router';
import { useClient } from 'hooks/useClient';
import { usePanelChannel } from 'hooks/usePanelChannel';
import { Heart, User, Disc3, Music, ChevronRight, X } from 'lucide-react';
import { useMusicPlayer, type PlayerTrack } from '../../MusicPlayer';
import { MusicHeart } from './MusicHeart';
import { useMusicFavorites } from './useMusicFavorites';
import { useMusicPlayer, type PlayerTrack } from 'officerdev';
import { MusicHeart } from 'officerdev';
import { useMusicFavorites } from 'officerdev';
import {
MUSIC_FAV_CHANNEL,
coverUrl,
@@ -35,8 +35,19 @@ export const FavoritesView = () => {
const albumRel = toRel(albumHome);
try {
const meta = await get<AlbumMeta>(`/music/meta?path=${encodeURIComponent(albumRel)}`);
const q: PlayerTrack[] = sortTracks(meta.tracks).map((t) => ({ albumRel, file: t.file, title: t.title, artist: t.artist }));
player.playQueue(q, Math.max(0, q.findIndex((t) => t.file === file)));
const q: PlayerTrack[] = sortTracks(meta.tracks).map((t) => ({
albumRel,
file: t.file,
title: t.title,
artist: t.artist,
}));
player.playQueue(
q,
Math.max(
0,
q.findIndex((t) => t.file === file),
),
);
} catch {
player.playQueue([{ albumRel, file }], 0);
}
@@ -63,7 +74,9 @@ export const FavoritesView = () => {
{empty ? (
<div className="flex flex-col items-center justify-center gap-3 py-24 text-center">
<Heart size={44} className="text-muted-foreground/30" />
<p className="text-sm text-muted-foreground">No favorites yet. Click the heart on any artist, album or track.</p>
<p className="text-sm text-muted-foreground">
No favorites yet. Click the heart on any artist, album or track.
</p>
</div>
) : (
<div className="flex flex-col gap-6">
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useRef } from 'react';
import { Loader2, Music4 } from 'lucide-react';
import type { LyricLine } from './lyrics';
import { seekPlayer } from './player-time';
import { seekPlayer } from 'officerdev';
import { useActiveLyricIndex } from './useLyrics';
type LyricsPaneProps = {
@@ -2,8 +2,8 @@ import { useClient } from 'hooks/useClient';
import { MicVocal } from 'lucide-react';
import { LyricsPane } from './LyricsPane';
import { useLyrics } from './useLyrics';
import { useLyricsOpen } from './useLyricsOpen';
import { useMusicPlayer } from './useMusicPlayer';
import { useLyricsOpen } from 'officerdev';
import { useMusicPlayer } from 'officerdev';
/**
* The right-hand half of the /music detail panel when lyrics are on. It follows the PLAYING track, not
@@ -4,15 +4,15 @@ import { Link, useNavigate } from 'react-router';
import { useClient } from 'hooks/useClient';
import { usePanelChannel } from 'hooks/usePanelChannel';
import { Play, Pause, ChevronLeft, MicVocal, Volume2 } from 'lucide-react';
import type { LayoutNode, PanelComponents } from '../../components/Workspace';
import { WorkspaceLayout } from '../../components/Workspace';
import { MusicHeart } from './MusicHeart';
import type { LayoutNode, PanelComponents } from 'officerdev';
import { WorkspaceLayout } from 'officerdev';
import { MusicHeart } from 'officerdev';
import { FavoritesView } from './FavoritesView';
import { useMusicPlayer } from '../../MusicPlayer';
import type { PlayerTrack } from '../../MusicPlayer';
import { LyricsPanel } from '../../MusicPlayer/LyricsPanel';
import { MusicMiniBar } from '../../MusicPlayer/MusicMiniBar';
import { useLyricsOpen } from '../../MusicPlayer/useLyricsOpen';
import { useMusicPlayer } from 'officerdev';
import type { PlayerTrack } from 'officerdev';
import { LyricsPanel } from './LyricsPanel';
import { MusicMiniBar } from './MusicMiniBar';
import { useLyricsOpen } from 'officerdev';
import {
MUSIC_ROOT,
MUSIC_FAV_CHANNEL,
@@ -1,11 +1,11 @@
import { useRef } from 'react';
import { useClient } from 'hooks/useClient';
import { MicVocal, Pause, Play } from 'lucide-react';
import { SeekBar } from '../apps/FileViewer/renderers/SeekBar';
import { coverUrl, fmtClock } from '../apps/Music/shared';
import { seekPlayer } from './player-time';
import { useLyricsOpen } from './useLyricsOpen';
import { useMusicPlayer } from './useMusicPlayer';
import { SeekBar } from 'officerdev';
import { coverUrl, fmtClock } from './shared';
import { seekPlayer } from 'officerdev';
import { useLyricsOpen } from 'officerdev';
import { useMusicPlayer } from 'officerdev';
import { usePlayerClock } from './usePlayerClock';
/**
+18
View File
@@ -0,0 +1,18 @@
import type { AppRegistryMeta } from 'officerdev';
import { Music, ListMusic } from 'lucide-react';
import { MusicBrowser } from './MusicBrowser';
import { MusicDetail } from './MusicDetail';
// The panels this plugin contributes. The shell renders `WorkspaceView` around them, arranged by
// `layout.ts` — a plugin never renders the screen.
//
// The two do not coordinate with each other: both read `?path=` off the URL, which is why there is no
// channel between them and why a library location is linkable and cmd-clickable. `MusicDetail` opens a
// NESTED workspace of its own for the lyrics split, which is a layout inside a panel rather than a second
// screen.
//
// `availableOnPanel: false` keeps them off the generic panel picker: they belong to this plugin's screen.
export const appRegistryMetas: AppRegistryMeta[] = [
{ key: 'music-browser', name: 'Library', icon: ListMusic, component: MusicBrowser, availableOnPanel: false },
{ key: 'music-detail', name: 'Music', icon: Music, component: MusicDetail, availableOnPanel: false },
];
+13
View File
@@ -0,0 +1,13 @@
// The library vocabulary, re-exported from the host.
//
// It lives at `officerdev/src/MusicPlayer/shared.ts` rather than here because `MusicPlayerHost` — the
// global player bar, which stays in the platform; see that directory's index.ts for why — needs a third
// of it. One definition on the host side beats a copy either side of the plugin boundary drifting apart.
//
// Taken from the `officerdev/MusicPlayer/shared` subpath rather than the `officerdev` barrel because the
// type names here (`DirEntry`, `Track`, `Manifest`) are ones the barrel already spends on the FileBrowser.
// The subpath is a declared export of the package (`"./*": "./src/*.ts"`), not a reach into its insides.
//
// Every panel in this directory imports from HERE, so the seam is one file to read rather than a
// different specifier in each of them.
export * from 'officerdev/MusicPlayer/shared';
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react';
import type { LyricLine } from './lyrics';
import { activeLineIndex, parseLyrics } from './lyrics';
import { getPlayerTime, subscribePlayerTime } from './player-time';
import { getPlayerTime, subscribePlayerTime } from 'officerdev';
export type UseLyrics = {
loading: boolean;
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { getPlayerDuration, getPlayerTime, subscribePlayerTime } from './player-time';
import { getPlayerDuration, getPlayerTime, subscribePlayerTime } from 'officerdev';
/**
* Position + duration, straight off the engine's per-frame feed.
+732
View File
@@ -0,0 +1,732 @@
# Offscale — the first real plugin
**Status: LIVE DOCUMENT, opened 2026-08-14, offscale extracted 2026-08-15.** Decisions and findings from
the session that built the plugin system. Correct it in place; it is meant to be edited, not archived.
It lives HERE, in the plugin, rather than in the platform's `docs/`. Most of it is about the plugin
system generally rather than about offscale, and that is deliberate: this is the worked example, and the
reasoning is most useful next to the code it produced. The platform's own docs should not carry the
history of something it no longer knows exists.
Offscale is Headscale extracted into a plugin. It is the pilot: chosen because it is a genuine vertical
slice (schema + backend router + sidecar + frontend screen + capabilities) without being pathological.
**The name is not a rename.** Offscale is Headscale _plus the Companion_ — an API and UI that ship beside
the Headscale server and add what Headscale itself does not do, the invite flow being the first of them.
Calling it Headscale would undersell it and calling it a fork would be wrong: the server underneath is
stock. The distinct name marks a distinct product, not a badge on someone else's.
Related, and older: `sidecar-app-store.md` is the origin design and is largely implemented despite its
"Nothing implemented" header. `sidecar-topology.md` is where the runtime shape was going.
---
## The reframe
**Core is `officer` and nothing else. Everything else is a plugin**`officer-pty`, `officer-opencode`,
`officer-claude-code`, offscale. `officer-anthropic-proxy` is a known exception to think about later; the
intuition is that it is one plugin requiring two sidecars.
The old baseline was six PM2 processes. Headscale was removed from it on 2026-08-14 (`services.sh`,
the local ecosystem file, `catalogue.test.ts`'s `CORE[]` mirror, and PM2 itself), so the machine this was
written on runs five.
### Two words, because "core" was doing two jobs
- **baseline** — what a fresh install actually runs
- **first-party** — what Officer Dev publishes
They come apart immediately: offscale is first-party and no longer baseline. Saying "core" for both makes
"is X core?" a question with two answers.
---
## What a plugin is made of
Combined per plugin as needed. **Only `meta` and the ID are always required.**
- a **meta** object — id, name, dock item, backend/frontend mount, etc.
- an **ID** (see below)
- a **sidecar**
- a **backend router** and its routes
- a **db schema**
- **default permissions per user group**
- what it stores in the **secret store**, and whether that is per-user or plugin-global
- a **frontend router**, its routes, and the frontend code
- how it **mounts into the file browser context menu**
- a set of **capabilities added to officer-items**
- **plugin settings page** definitions
- an accompanying **mobile app**
A plugin is completely self-contained. The platform's installed/enabled state decides whether its routers
mount, whether its sidecar is in the ecosystem file, and so on.
### What offscale needs
db schema · backend router + routes · frontend router + routes · sidecar.
**Not** a context menu, **not** officer-items capabilities, and (probably) **not** a settings page.
---
## Identity and routing
**The app-name is the ID.** One identifier, not two — it names the plugin, prefixes its tables, and is its
route. A random ID plus a separate app-name was considered and dropped: splitting the uniqueness guarantee
across two namespaces means whichever is weaker becomes the real attack surface.
**Uniqueness comes from two mechanisms**, because one is not enough:
- **globally** — the marketplace owns the namespace for published names, with human review. A name as
generic as `notes` gets refused: it is a name Officer Dev may want later.
- **locally** — the platform refuses to install a plugin whose app-name is already taken on this machine.
Needed because a private plugin never asks the marketplace anything.
The marketplace works like the Chrome extension store. Anyone may write plugins for their own use with no
restrictions; publishing is what invites review.
### Mount prefixes
```
first-party /api/<app-name> e.g. /api/offscale
third-party /api/p/<creator>/<app-name> e.g. /api/p/alice/notes
```
`p` is a literal segment meaning "plugin". First-party plugins sit at the root because Officer Dev owns
that namespace anyway, and because provenance is then legible at a glance in a log or a route table.
**The prefix must be derived by exactly one function from the manifest.** Nothing about a first-party
plugin's code may know it is first-party. If that difference ever leaks past the one derivation — a
special case in the router, a bypassed check, a different install branch — first-party and third-party
become two systems, and only one of them gets tested.
`/p/` does **not** solve plugin-vs-plugin collisions; the marketplace and the local check do. What it
guarantees is that a plugin can never shadow a **core** route, which also means the platform can keep
adding core routes forever without breaking installs.
---
## The database
**Tables live in `public`, prefixed with the app-name**`offscale_servers`, exactly as the codebase
already does (`headscale_servers`, `music_favorites`, `vault_tokens`). No new machinery.
### A Postgres schema per plugin was tested and rejected
Not rejected on suspicion — it was built and proven to work, then dropped as more complexity than it
earns. Recorded so nobody re-runs the experiment:
| Property | Result |
| ----------------------------------------------------------------- | ------------------------- |
| `pgSchema('offscale')` + `drizzle-kit push` creates the namespace | works |
| Cross-schema FK to `public.users` | works |
| Partial unique index preserved | works |
| Push is idempotent, no spurious re-creation | works |
| Cascade delete across the schema boundary | works |
| `DROP SCHEMA offscale CASCADE` as uninstall | works, `public` untouched |
**The finding worth keeping: `schemaFilter` is mandatory, and the docs are wrong.** Drizzle's config
documentation states that push "will by default manage all schemas". On drizzle-kit **0.31.8** that is
false. A push with the table verifiably exported reported `No changes detected` and created nothing;
naming the schema in `schemaFilter` made the identical push work.
If per-plugin schemas are ever revisited, that is the trap: **a plugin install would report success and
silently create no tables.** Same failure shape as several bugs found the same day — a refusal wearing the
costume of a normal result.
---
## Mounting — rebuild and swap, at runtime
**Runtime mounting, no restart.** This went round twice — C, then B on the belief that Hono could not
mount at runtime, then back — so the reasoning is recorded rather than the conclusion alone.
### What was actually tested
| Router | `app.route()` after serving has begun |
| -------------------------------- | --------------------------------------------------------------------- |
| `SmartRouter` _(Hono's default)_ | **throws**`Can not add a route since the matcher is already built` |
| `RegExpRouter` | **throws**, same reason |
| `TrieRouter` | works |
| `PatternRouter` | works |
So adding at runtime is possible, but only by giving up the fast matcher — and Hono has **no API to
remove a route**, which uninstall needs.
### The approach that solves both
Rebuild the whole app from the current plugin set and **reassign the variable**:
```ts
let app = buildApp(installedPlugins()); // core routes + one .route() per plugin
serve({ fetch: (req, server) => app.fetch(req, server) }); // closure, NOT app.fetch
// install: app = buildApp([...installed, 'offscale'])
// uninstall: app = buildApp(installed.filter(p => p !== 'offscale'))
```
The `fetch` closure reads `app` on every request, so reassigning it **is** the swap. Verified end to end:
```
no plugins /offscale/x -> 404 | /core -> 200
installed /offscale/x -> 200 | /core -> 200
uninstalled /offscale/x -> 404 | /core -> 200
```
Better than the TrieRouter route on both counts: the default `SmartRouter` is kept, so the fast
`RegExpRouter` path survives — and **uninstall works**, which an add-only API cannot express.
### The one line that has to change
`server.tsx:322` is `'/api/*': honoServer.fetch` — a **bound method**, evaluated once at `serve()`. It has
to become `(req, server) => honoServer.fetch(req, server)`, or reassigning the app has no effect at all.
This is the whole mechanical cost.
### Websockets are a separate table, and they reload
Six providers are declared in **Bun's route table**, not Hono's: `/api/tasks/run/ws`,
`/api/tasks/pipeline/ws`, `/api/terminal/ws`, `/api/chat/ws`, `/api/cliamp/ws`, `/api/cliamp/audio/ws`.
The Hono swap does not reach them — but `server.reload({ routes })` does, in both directions:
```
before reload /api/offscale/ws -> refused | /core -> 200
after reload /api/offscale/ws -> CONNECTED | /core -> 200
after remove /api/offscale/ws -> refused | /core -> 200
```
So **nothing needs a restart, for either table.** A plugin owning a socket is possible from the start.
`reload` wants the whole option set, so `fetch` is passed alongside `routes`.
`[open]` Whether connections already open across a `reload` survive it was not tested. Worth knowing
before a plugin install can interrupt somebody's terminal.
The two tables remain two lists, which is the same seam as the totality bug below.
### What this means for `assertCapabilityTotality`
It can no longer be only a boot check, because the mount set changes after boot. The question moves to
**per rebuild**: `buildApp()` is the one place routes are mounted, so it is the one place to assert that
every mounted route has a permission — and to refuse the swap if one does not. Same invariant, asserted
where mounting actually happens instead of once at start-up.
Two things it must survive, both live today:
- The premise in `sidecar-app-store.md` that "every API route stays mounted regardless" is **retired**. An
uninstalled plugin's routes are not mounted, so nothing can reach them.
- The check is currently **fed the wrong list**`Object.keys(handlers)` from `server.tsx`, while Bun
serves the _route table_, and the two diverged when plugins were switched off. Moving the assertion into
`buildApp()` fixes this by construction for Hono routes, and leaves the websocket table as the part that
still needs pointing at reality.
---
## Permissions
A plugin declares capabilities. **A plugin may declare `app`, and nothing else.**
`CapabilityKind` is `core | app | confined | execution | admin`. `core` means _every account, not
deniable_, so a third-party manifest naming its own kind is a privilege-escalation surface: "malicious
plugin declares itself core" is an ungated grant to every user. `core`, `execution` and `admin` stay the
platform's to assign.
### The platform grants read or write. Everything richer is the plugin's own job
The platform's contract is exactly what it already has and no more: **a role holds `read` or `write` on a
capability**, stored in `role_capabilities`, enforced by the gate. `read` permits safe methods anywhere in
the surface; `write` permits everything.
Anything beyond that — who may see whose rows, per-user isolation, ownership of individual records,
visibility rules of any kind — is **implemented inside the plugin**, by the plugin's author. It is not the
platform's responsibility and the platform should not grow machinery for it. A plugin knows what its data
means; the platform only knows whether this account got through the door.
### Offscale v1 uses that model exactly, with nothing added
One shared resource, role-gated:
- **read** — sees what the owner sees: the owner's registered servers, nodes, users, keys, policy
- **write** — can change them, including deleting a server the owner registered
The second is genuinely dangerous, and deliberately allowed. The stored credential is a Headscale **admin**
key that can delete every node on a tailnet, and there is no read-only version of it. So `write` on
offscale is close to full control of the tailnet — which is the owner's decision to make, and the expected
use is read for most roles. Say Developers get `read` and nobody gets `write`.
Two implementation consequences, both inside the plugin:
1. **The queries stop scoping by the caller.** Every one takes the caller's `userId` today —
`listHeadscaleServers(userId)`, `getActiveHeadscaleCredentials(userId)` — and the schema is per-user
because of it. Under this model a member sees the **owner's** rows, so those resolve to the owner's id
always. The per-user shape stays in the table, unused, and becomes the seam if isolation is ever wanted.
2. **Two POSTs are really reads, and must be declared `readOnlyWrites`:**
- `POST /ssh-test` — a reachability probe that mutates nothing
- `POST /policy/assist` — proposes a document and, emphatically, never saves one
Without them a read-level account cannot test a connection or draft a policy, which reads as a broken
feature rather than a withheld permission. Everything else — activate, rename, tags, routes, expire,
delete, policy `PUT` — is a genuine write.
### Music is where the richer model gets designed
Offscale is deliberately the simple case. **The next plugin extracted is most likely music, and that is
the right place to develop the in-plugin visibility system** — it has genuinely per-user data (favourites,
playlists, now-playing) sitting on top of a genuinely shared one (a single global library index, noted in
`TODO.md` as one household, one library). So "whose is this row" has a real and non-uniform answer there,
where offscale's is just "the owner's".
Not designed yet, and deliberately not designed here. Recorded so the intent survives.
### Three different things are called "capability" here
A manifest needs three names, not one:
1. `capabilities/registry.ts`**permissions** (`headscale`, `vpn`)
2. `$OFFICER_ROOT/capabilities/` — the **file-based item store** (skills, tools, tasks)
3. `sidecar-registry` `capabilities: ['music']`**routing keys** for `sendCommand`
Offscale needs (1) and (3), and not (2).
---
## Secrets
Two stores, and a plugin author will reach for the wrong one unless told:
- **plugin-global keys** → the secret store (`officer_db/src/secret-store.ts`, real: `getKey(purpose)`,
`hasKey`, `retiredKeys`). Purpose-keyed encryption and signing keys, not arbitrary values.
- **per-user credentials** → `service_connections`, which already does the hard part: the row is keyed
`(userId, service)` and **a NULL `url` means "inherit the instance"**, so a member structurally cannot
see or supply the URL. `service` is free text with no namespacing yet — that needs solving before third
parties touch it.
Offscale's own coupling is small and instructive. `headscale/queries.ts` imports exactly two things from
the host:
```ts
import { db } from '../db'; // the connection
import { encryptSecret, decryptSecret } from '../crypto'; // at-rest encryption, 10 uses
```
A plugin cannot carry its own `db` (it must share the connection to reference `users.id`) and should not
carry its own crypto (the key lives in the platform's store). **So those two are provided to a plugin
rather than imported by it.** That is the first concrete piece of the plugin↔host API, and it fell out of
the pilot rather than being invented.
---
## `/api/vpn` is being deleted
Officer had two headscale surfaces:
| | `/api/vpn` | `/api/headscale` |
| ---------- | ---------------------------------------- | -------------------------------------- |
| capability | `vpn`, kind `app` — grantable to members | `headscale`, kind `admin` — owner only |
| purpose | enrol your own device | the tailnet: machines, routes, ACLs |
| surface | one route, `POST /enroll` | the whole admin API |
`POST /api/vpn/enroll` was one-tap enrollment for a phone already signed into Officer. **It has no caller
anywhere.** Verified against the mobile monorepo:
1. `enrollVpn()` has one call site, `useVpnScreen.ts:617`, inside `enroll()`
2. `enroll()` is reached only via `if (embedded) await enroll()`
3. `embedded` is optional and defaults to `false`
4. `VpnScreen` is rendered in exactly one place — `apps/offscale/src/App.tsx` — which never passes it
`apps/mobile` and `apps/headscale` have zero references to `enrollVpn`, `VpnScreen` or `api/vpn`. Neither
does the Officer web app. The live database holds no `vpn` grants.
**And it will never come back.** Offscale is permanently standalone: no login, no backend calls, no
dependency on Officer or the platform. The reasoning is the app's own and it is sound — _the thing that
gets you to the platform cannot itself need the platform_, or a broken tailnet locks you out of both.
### Everything collapses to one namespace
`/api/offscale/*`. The comment in `vpn/router.ts` claiming "the path is a contract" no longer binds: the
contract has no counterparty.
**The invite flow stays and does not need the mobile app changed.** `claimInvite` calls
`${invite.base}/api/v1/enroll/claim` — the **Companion** on the server, at a base URL carried in the
invite link. `/api/v1/` is Headscale's own namespace. The phone never talks to Officer for invites.
- **phone → Companion** — untouched by anything here
- **web admin → Officer → sidecar** — ours to rename freely
### There are THREE components, not two
Easy to miss, and worth stating because two of them contain the word "enroll":
| Component | Repo | Enrolment surface |
| ---------------- | ---------------------------- | ------------------------------------------------ |
| Officer platform | `officerdev/platform` | `/api/offscale/*` — web admin only |
| Mobile suite | `officerdev/monorepo-mobile` | calls the Companion, never Officer |
| **Companion** | `officerdev/offscale-server` | `/api/v1/enroll/*` under basePath `/officer-api` |
The Companion ships beside each Headscale server. Confirmed against its source on 2026-08-14: zero
references to `/api/vpn/*`, and its only outbound calls are the docker socket and its sibling headscale's
`/health`. It never calls Officer and does not use `/api/offscale/*` either.
**`/api/v1/enroll/*` is the Companion's and is not ours to collapse.** The phone claims at
`${invite.base}/api/v1/enroll/claim`, where `invite.base` is the `sidecarOrigin` the Companion itself put
in the invite (`https://<domain>/officer-api`).
**Trap when deleting:** do not delete the sidecar's `enroll.ts`. Line 71 dispatches
`/enroll/invites` to `handleInvitesRoute`, so it is the invite flow's entry point. Only the bare
`POST /_officer/enroll` handler below it is dead.
**A public route is possible if ever needed.** `/api/vault` is already exempt from platform auth
(`EXEMPT_API_PREFIXES`) because Bitwarden clients carry a Vaultwarden bearer rather than a platform JWT.
The exemption must be declared with a reason or the boot check refuses. Not needed today.
**Not an open question — decided.** Removing `vpn` leaves no member-grantable headscale surface, and that
is correct. The invite flow supersedes it completely:
1. the Officer headscale app holds an admin API key for the Headscale server
2. from it the owner mints an **invite** — a URL pointing at the Companion
3. the Companion turns that into the redirect the phone app claims
4. the device joins
That path needs no per-member permission on Officer at all, and it is the one that exists and works.
`/api/vpn/enroll` was the design it replaced, not a capability still waiting for a UI — there never was
one. Do not reintroduce a member-facing enrolment route on the assumption something is missing.
---
## What headscale actually is — the inventory
Read end to end on 2026-08-14. This is what has to move.
### Backend — 2,406 lines
`/api/headscale` is **18 lines**: a pure `createSidecarProxy`, no Headscale knowledge, "must never grow app
logic". Everything is in the sidecar under `/_officer/*`, dispatched by `routes.ts` to eight handlers —
`servers · nodes · users · keys · policy · enroll · ssh-test · companion`.
Three things worth knowing before touching it:
- **Every domain route acts on the _active_ server**, stored in Postgres behind a partial unique index and
never passed as a parameter — so no client can act on a server the owner is not currently looking at.
- **`client.ts` is a quirk-absorption layer, and that is the good part.** The quirks are Headscale's:
uint64 ids arrive as JSON _strings_ (never round-trip through `Number` — it breaks above 2^53), 401/403
bodies are plain text while every other error is JSON, and the gateway uses `DiscardUnknown` so a
misspelled request field makes the call **succeed and do nothing** — which is why mutations read the
object back. One file containing all of it is the model for a plugin's client layer, not something to
undo.
- **The Companion is optional per server** and answers `{available:false, reason}` at HTTP 200. The trick
is distinguishing nginx's HTML 502 (no companion) from the companion's JSON 502 (docker op failed): it
branches on whether the body parses.
Host dependencies: `officerdb` (db + crypto), `DATA_PATH`, `officer-url.mjs`, `createSidecarConnector`,
`createSidecarProxy`, the anthropic proxy's state file, and the `ssh` binary.
### Frontend — 29 files, 27 endpoints
Three registered panels (`headscale-servers`, `headscale-nav`, `headscale-view`, all
`availableOnPanel: false`) inside a locked `WorkspaceView`, with `headscale-view` dispatching on
`useHeadscaleSection()` to eight section views: Servers · Nodes · Users · Keys · Invites · Policy ·
Diagnostics · Console.
It **follows the navigation conventions** — no `usePanelChannel` anywhere, no opaque clicks, the section
lives in `:section` and nowhere else. The one exception is documented and correct: choosing the active
server is a DB write that re-scopes every query, so it stays a button rather than a URL.
The whole frontend↔host coupling, which becomes the plugin API:
| Import | Why it matters |
| --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| `hooks/useClient``useClient`, `getHeaders` | both, not just the client — `useCompanionLogStream` needs raw headers because `EventSource` cannot send `Authorization` |
| `helpers/clipboard``copyToClipboard` | carries the non-secure-context fallback; re-implementing it would silently regress |
| `AppRegistryMeta` | the panel-contribution contract |
| `officerdev``WorkspaceView`, `LayoutNode` | needs `appTypes: {allowed, fallback}` and `locked` |
| `state/useDashboardState` | per-user layout, backed by `/api/dashboards`, a `core` capability — stays host-provided |
| `../Terminal/Terminal``TerminalView` | **the awkward one** — a code dependency on another panel app |
### `assist.ts` travels, but stays unwired
The ACL-drafting assistant was written and never tested. **Carry it into the plugin, do not delete it, and
do not wire it up** — it is there as a marker that the idea exists, to be finished or removed deliberately
later. Do not tidy it away as unused code.
---
## The manifest — proposal
Written against offscale rather than invented in the abstract, on the principle that a field list designed
from nothing includes what nothing needs and misses what is awkward. The field set grows per plugin; this
is the floor, not the ceiling.
```ts
// plugins/offscale/manifest.ts
export const manifest = {
/** Constant today. The one input to `mountPrefix()`, and the seam third parties hang off later. */
publisher: 'officerdev',
/** The plugin's own semver. Updates compare against this. */
version: '1.0.0',
/** Which platforms this build is good for. Refused at install when it does not match. */
platform: '>=1.0.0 <2.0.0',
label: 'Offscale',
summary: 'Your tailnet — machines, users, pre-auth keys and access policy',
icon: 'Network',
color: '#818cf8',
// Named `permissions`, NOT `capabilities`. That word already means three different things here — the
// permission registry, the officer-items store, and the sidecar's routing keys — and a fourth would be
// one too many. `permissions` is accurate and free: the old table of that name went in 044aacf4.
permissions: [
{
key: 'offscale',
label: 'Offscale',
description: 'The tailnet: machines, routes and ACLs',
/** Owner-only, or grantable to members. The whole distinction a plugin needs. */
ownerOnly: true,
},
],
} as const;
```
### THE RULE: every plugin route renders a Workspace with at least one panel
Exclusionary, and enforced by shape rather than by review. A plugin **does not render a screen.** It
contributes panels and says how they are arranged; the shell renders `WorkspaceView` around them.
```
web/panels.ts exports appRegistryMetas — at least one panel
web/layout.ts exports defaultLayout — how they are arranged
```
Both are required the moment `web/` exists. Missing either and the plugin is **refused at discovery**, by
name and with the reason:
```
probeplug: has a web/ directory but is missing web/layout.ts.
Every plugin route renders a Workspace: contribute panels and a layout, not a screen.
```
There is deliberately no way to export a component. A plugin that could would be free to render a bare
div, a full-page form, its own navigation — and the platform would become a shell hosting strangers'
layouts rather than one application. Non-compliance is not refused so much as **unrepresentable**: there
is nowhere to put a screen.
The shell registers the pair `<prefix>` and `<prefix>/:section`, exactly as the core screens do
(`/headscale/:section`), so a plugin's sections stay addressable, linkable and cmd-clickable. Panels read
`useParams` independently — nothing is passed between them, so they cannot disagree. `appTypes.allowed`
is pinned to that plugin's own panel keys, so a persisted layout naming something else falls back rather
than rendering another plugin's panel inside this one.
### Everything the tree can say, the tree says
The manifest holds only what a directory listing genuinely cannot tell you: an identity fact, or something
a human chose. Everything structural is convention, and **presence is the declaration**:
| Path | Means |
| -------------------- | --------------------------------------------------------------------------------------------- |
| _the directory name_ | `appName``plugins/offscale/` **is** the id, so it cannot disagree with where the code sits |
| `sidecar/index.ts` | there is a sidecar; PM2 gets an entry. `.mjs` instead means node — see below |
| `api/router.ts` | there is a backend router, mounted at `mountPrefix(manifest)` |
| `db/schema.ts` | there are tables; pushed on install, every name prefixed `offscale_` |
| `web/Router.tsx` | there is a frontend; its default export mounts at `<prefix>/*` |
| `web/panels.ts` | it contributes panels; exports `appRegistryMetas` |
The dock tile and the page title need no fields either — the tile is `{ label, icon, color, to:
mountPrefix(manifest) }` and the title is `label`, all of which are already above. Writing them again was
duplication that could only ever drift.
**The runtime is the file extension.** `sidecar/index.mjs` runs under node, `sidecar/index.ts` under bun.
Implicit, but it is the rule this repo already follows — `officer-pty` is `pty/index.mjs` under node
because node-pty is a native module built against Node's ABI, and everything else is bun. Better than a
field that can contradict the file it describes.
### Install asks nothing, and that is the default
Offscale needs **none** of the install fields the current app-store catalogue carries — no `modes`, no
`existingFields`, no `configFields`, no `composeTemplate`, no `members`. There is no Docker to provision
and no external service to point at.
Its install is the whole of it: put the code there, push the schema, start the sidecar, swap the routes.
Available immediately. Everything else is configuration the user does **afterwards, inside the app** — a
Headscale server is registered at `/offscale/servers` and lands in `offscale_servers`, which is already
how it works today.
So the rule is **a plugin installs with no questions unless it says otherwise**, and the prompting
machinery (the three install shapes in `sidecar-app-store.md`) gets designed against the first extracted
plugin that actually needs Docker or a remote instance. That was part of why offscale is the right pilot:
it exercises the mounting, the schema and the sidecar without the install flow being a variable too.
### Dropped from the first draft
- **`dependsOn`** — nothing read it and nothing enforced it. Both of offscale's dependencies already
explain themselves where it matters (`assistant_unavailable`; "no SSH host configured"). A field whose
only job is to be displayed, that nothing displays, is stale the first time anyone looks at it. Add it
when something consumes it.
- **`kind`** — see below.
- **`sidecar` / `schema` / `frontend` objects** — all convention now.
`[open]` A plugin with a frontend that should NOT get a dock tile has no way to say so: `web/` present
means a tile. Fine for offscale; add a flag the first time something needs it.
### `admin` has to be allowed, and the pilot proved it immediately
The earlier rule here was "a plugin may declare `app`, and nothing else". **That is wrong, and offscale is
the counterexample**: its capability is `kind: 'admin'` — owner-only — and it should stay that way.
The distinction is direction. `core` means _every account, undeniable_, so a plugin claiming it grants
itself to everyone: escalation. `admin` means _owner only_, which is a plugin **restricting** itself, and
nothing is gained by forbidding it.
Corrected rule:
| Kind | May a plugin declare it? | Why |
| ----------- | ------------------------ | ---------------------------------------------------------- |
| `app` | yes | the ordinary grantable surface |
| `admin` | yes | self-restriction, never an escalation |
| `core` | **no** | every account, not deniable — an ungated grant to everyone |
| `execution` | **no** | runs as the owner's OS user; the platform's to assign |
| `confined` | **no** | implies a Linux identity the platform provisions |
### One function decides the prefix
`publisher` is the only input, so first-party and third-party cannot become two code paths:
```ts
const mountPrefix = (m: Manifest) =>
m.publisher === 'officerdev' ? `/${m.appName}` : `/p/${m.publisher}/${m.appName}`;
```
Used for both `/api/...` and the frontend route. Nothing else in the codebase may branch on provenance.
### Notes on the fields
- **`sidecar.runtime`** exists because `officer-pty` runs under node for node-pty's native ABI while
everything else is bun. One plugin already needs it, so it is not speculative generality.
- **`platform`** is the compat range, and it presumes the platform gains a version. It has none today;
1.0 is expected before anyone outside Officer Dev writes a plugin.
- **`dependsOn`** is deliberately not enforced. Code dependencies need no declaration — a plugin builds
inside the workspace, so `import { TerminalView }` simply resolves — and service dependencies already
degrade. This is for the human reading the store.
- **No `health`.** Deferred; process-online is what the store knows and that is enough for now.
- **No `migrations`.** Deferred; a field can be added without redesign.
- **No permission list.** A plugin calls the API with the user's token and the user's permissions.
---
## What is built — complete, as of 2026-08-15
**Offscale is a plugin, and nothing in the system is a stub.** Validated by the owner against the live
server across repeated install / enable / disable / uninstall cycles, checking PM2 and the frontend each
time.
| Piece | Where |
| --------------------------------------- | ---------------------------------------------------- |
| Manifest, `mountPrefix`, validation | `servers/plugins/manifest.ts` |
| Discovery by convention | `servers/plugins/discover.ts` |
| Disk ⋈ database, mounts, dock manifests | `servers/plugins/mount.ts` |
| Install runner, four verbs, streamed | `servers/plugins/install.ts` |
| PM2 ecosystem entry | `servers/plugins/ecosystem.ts` |
| Schema barrel + `db:push` | `servers/plugins/schema.ts` |
| `Plugins.gen.tsx` + `Bun.build` | `servers/plugins/generate.ts` |
| `buildHonoApp` / `rebuildHonoApp` | `servers/hono.ts` |
| Capability registration | `capabilities/registry.ts``setPluginCapabilities` |
| Install state | `plugin_installs` |
| The screen | `/plugins`, two panels, SSE log |
| The reference plugin | `plugins/example/` |
| **The first real plugin** | `plugins/offscale/` — 45 files |
Nothing needs a restart. Routes swap by rebuilding the Hono app, the sidecar gets a PM2 entry, the
frontend is regenerated and rebuilt in ~3s, capabilities are registered before routes mount, and the
whole thing survives a restart because boot regenerates and mounts before `serve()`.
### Three bugs the extraction found
Worth recording because none were visible from reading:
1. **Install started the sidecar before mounting.** `createSidecarProxy` learns its port from a one-shot
`<name>:server` event and subscribes when the plugin's router is first imported — at mount. So the
announcement fired into a void: process online, routes mounted, every request `503 sidecar not
available`. It would have hit every plugin with an HTTP sidecar; `example` never caught it because it
has no listener to announce. Install and enable now mount first.
2. **The built SPA had no Tailwind.** `bunfig.toml` declares the plugin under `[serve.static]`, which
applies to Bun's static serving and not to a programmatic `Bun.build()`.
3. **The build could destroy itself.** Clearing `build/` before building meant a failed build left
nothing, and two overlapping builds could delete each other's shell. It now stages and swaps.
### Still open
- **Websocket providers.** `server.reload({ routes })` is proven but not called; Bun's route table is
still the hardcoded providers. No plugin owns a socket yet.
- **Totality across plugin routes.** `PROTECTED_API_PREFIXES` is still the core list, and the check reads
`Object.keys(handlers)` while Bun serves the route table. The assertion wants moving into
`buildHonoApp`, which is now the single place routes are mounted.
- **Two dock sources.** The app store keeps its own catalogue, so tiles come from there and from the
plugin system. One when the app store is rebuilt on this.
- **Members.** Offscale is `ownerOnly` — read/write for members needs its queries resolving to the
OWNER's rows rather than the caller's, which is a change inside the plugin.
---
## The state of the app store, as found
It **is** the plugin system, roughly 90% built, with one structural hole.
`ecosystem.config.cjs` is generated once at setup and **nothing appends to it on install**, so the
installer's final step runs `pm2 start ecosystem.config.cjs --only officer-jellyfin`, matches no app, and
silently does nothing. Acknowledged in `app-store/pm2.ts:23-29`:
> _"Installing a plugin has to append its entry here before starting it — that is the plugin system's job
> and it is not built."_
Net: **nothing in the catalogue installs end-to-end today.** Containers come up, `service_connections` is
written, assets publish, the dock tile appears — and the sidecar never starts.
Also found:
- The `schema` install step is a **logged no-op** (`effects.ts:117-124`). Every table still ships via
`bun db:push`.
- Of 8 entries declaring a compose template, **only 2 exist on disk** (`transmission`, `vaultwarden`).
`slskd` has an icon and nothing else. `catalogue.test.ts` asserts a template _name_ is declared but never
that the directory exists.
- `hono.ts` has **28 routers mounted and 15 commented out**; `officer_db/src/schema.ts` has **11 commented
schema exports** under "uncomment when the plugin is installed". Today, installing a plugin literally
means editing two files and rebuilding.
- `catalogue.test.ts` asserts every entry's process has a matching `src/servers/sidecar/<dir>`. A plugin in
its own repository has no such directory, so that test inverts — as `sidecar-app-store.md` predicted.
- A **dead, unrelated** plugin system still exists: `GET /server-settings/plugins` scans
`src/workspaces/plugins/`, which does not exist, so it always returns `[]`. `PluginsSection.tsx` still
renders against it. Not to be confused with any of the above.
---
## Where the code lives
`plugins/offscale` on `gitea.officer.dev` — private, default branch `main`, topic `officer-plugin`.
The `plugins` org exists because Gitea has **no nested organizations** (verified: no `parent` field on the
org object), so `<owner>/<repo>` is the only real namespace it has. Topics work and are searchable, and are
used in addition rather than instead — they span orgs, which matters because browser extensions under
`extensions/` may become plugins later.
---
## Open questions
1. ~~**Frontend code is the hard one.**~~ **Answered** — see "How the frontend ships". Build to `build/`,
rebuild on install, one generated `Plugins.tsx`, same origin. No federation, no import maps, no iframe:
everything compiles together and a plugin changes what "everything" is. The developer builds inside a
platform checkout, so dev-time and build-time are the same mechanism.
2. **Migrations and versioning.** A plugin needs a version and a platform-compatibility range, and
something has to apply schema changes over time. Cheap now, miserable to retrofit.
3. ~~**Health, distinct from enabled.**~~ **Deferred, deliberately.** A sidecar can be online while the
thing it exists to talk to is unreachable — offscale's own `/servers/:id/health` is exactly that
question. But process-online covers the common failure, every plugin that needs more surfaces it in its
own UI, and this is a manifest field that can be added later without redesign. Revisit in a distant
future, not before.
4. ~~**No inter-plugin dependencies.**~~ **Overtaken by evidence.** That measurement was of _schemas_ and is
still true there; at runtime the pilot has two — `assist` → anthropic-proxy (service) and `ConsoleView`
`TerminalView` (code). The rule became "may depend, must degrade" — see "Dependencies between
plugins". What is still open is the **code** kind: either `TerminalView` becomes host API, or the
Console section does not travel with the plugin.
5. **`service_connections.service` namespacing** before third parties touch it.
6. **`officer-anthropic-proxy`** — one plugin, two sidecars.
7. **Gitea is installed but invisible.** Containers `gitea` and `gitea-postgres` run, `officer-gitea` is
not in PM2, and there is no `sidecar_installs` row — it predates the store. "Already there, but not by
us" needs an answer, and the store deliberately refuses to adopt directories it did not create.
@@ -1,4 +1,4 @@
import { createSidecarProxy } from '../../sidecar/create-proxy';
import { createSidecarProxy } from '@@/sidecar/create-proxy';
// /api/headscale/* — auth, then forward to officer-headscale. No routes of its own and no headscale knowledge:
// this file must never grow app logic.
@@ -9,10 +9,10 @@ import { createSidecarProxy } from '../../sidecar/create-proxy';
const proxy = createSidecarProxy({
name: 'headscale',
prefix: '/api/headscale',
prefix: '/api/offscale',
});
export const headscaleRouter = proxy.router;
export const router = proxy.router;
/** Base URL of the sidecar's HTTP server, or null if it hasn't reported in yet. */
export const getHeadscaleServerUrl = proxy.getHttpUrl;
@@ -1,7 +1,7 @@
import { eq, and, desc } from 'drizzle-orm';
import { db } from '../db';
import { headscaleServers } from '../schema';
import { encryptSecret, decryptSecret } from '../crypto';
import { db } from 'officerdb/db';
import { headscaleServers } from './schema';
import { encryptSecret, decryptSecret } from 'officerdb/crypto';
// Headscale server registry access for the officer-headscale sidecar. Callers deal in PLAINTEXT —
// encryption to/from at-rest ciphertext happens here, so the sidecar's route handlers never touch crypto.
@@ -54,7 +54,7 @@ export async function getActiveHeadscaleCredentials(userId: number): Promise<Hea
.from(headscaleServers)
.where(and(eq(headscaleServers.userId, userId), eq(headscaleServers.isActive, true)));
if (!row) return null;
return { id: row.id, name: row.name, url: row.url, apiKey: decryptSecret(row.apiKey) };
return { id: row.id, name: row.name, url: row.url, apiKey: decryptSecret('headscale', row.apiKey) };
}
/** One server's credentials by id — for probing a specific server rather than the active one. */
@@ -64,7 +64,7 @@ export async function getHeadscaleCredentials(userId: number, id: number): Promi
.from(headscaleServers)
.where(and(eq(headscaleServers.userId, userId), eq(headscaleServers.id, id)));
if (!row) return null;
return { id: row.id, name: row.name, url: row.url, apiKey: decryptSecret(row.apiKey) };
return { id: row.id, name: row.name, url: row.url, apiKey: decryptSecret('headscale', row.apiKey) };
}
type CreateHeadscaleServerParams = {
@@ -95,7 +95,7 @@ export async function createHeadscaleServer(params: CreateHeadscaleServerParams)
userId,
name,
url,
apiKey: encryptSecret(apiKey),
apiKey: encryptSecret('headscale', apiKey),
version,
sshHost,
isActive: activate,
@@ -119,7 +119,7 @@ export async function updateHeadscaleServer(
const set: Record<string, unknown> = { updatedAt: new Date() };
if (params.name !== undefined) set.name = params.name;
if (params.url !== undefined) set.url = params.url;
if (params.apiKey !== undefined) set.apiKey = encryptSecret(params.apiKey);
if (params.apiKey !== undefined) set.apiKey = encryptSecret('headscale', params.apiKey);
if (params.sshHost !== undefined) set.sshHost = params.sshHost;
const [row] = await db
@@ -1,6 +1,6 @@
import { pgTable, serial, integer, text, boolean, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
import { users } from './auth';
import { users } from 'officerdb/auth/schema';
// The Headscale servers the owner manages, for the officer-headscale sidecar. Officer targets no single
// Headscale: the owner registers one or more servers (URL + an admin API key generated on that server) and
+47
View File
@@ -0,0 +1,47 @@
import type { PluginManifest } from '@@/plugins/manifest';
// Offscale — Headscale, plus the Companion that ships beside it.
//
// Not a rename of Headscale and not a fork: the server underneath is stock, and the Companion adds what
// Headscale itself does not do — the invite flow being the first of them. The distinct name marks a
// distinct product rather than a badge on someone else's.
//
// The first real plugin, extracted from the platform on 2026-08-15. Everything it needs is here:
//
// api/router.ts a thin auth-gated proxy — no Headscale knowledge, and it must never grow any
// sidecar/ the whole Headscale contract, holding the admin API keys
// db/ offscale_servers, and the only table this plugin owns
// web/ panels and a layout; the shell renders the Workspace
export const manifest: PluginManifest = {
publisher: 'officerdev',
version: '1.0.0',
platform: '>=1.0.0',
label: 'Offscale',
summary: 'Your tailnet — machines, users, pre-auth keys, access policy and device invites',
icon: 'Network',
color: '#818cf8',
// One permission gating the whole surface, grantable per role at read or write like every other.
//
// `[open]` What a member's grant MEANS here is this plugin's own job and is not finished. The queries
// still scope by the caller (`listHeadscaleServers(userId)`), so a granted member would see their own
// empty server list rather than the owner's, and could register a Headscale of their own. The model in
// ./PLUGIN.md is one shared resource: read sees what the owner sees, write can change it.
// That is a change inside these queries, not a flag on the manifest.
//
// Worth knowing while it is unfinished: the stored credential is a Headscale ADMIN api key that can
// delete every node on a tailnet, and there is no read-only version of it — so `write` here is close to
// full control of the tailnet, which is the owner's decision to make deliberately.
permissions: [
{
key: 'offscale',
label: 'Offscale',
description: 'The tailnet: machines, routes, keys and ACLs',
// Two POSTs that are really reads — a reachability probe and a policy DRAFT that never saves.
// Without declaring them a read-level account meets a broken feature where a withheld permission
// should be. Inert while ownerOnly, and correct the moment that changes.
readOnlyWrites: ['/ssh-test', '/policy/assist'],
},
],
};
@@ -1,4 +1,4 @@
import { getActiveHeadscaleCredentials } from 'officerdb';
import { getActiveHeadscaleCredentials } from '../db/queries';
import { createClient, type HeadscaleClient } from './client';
// Every domain route acts on the ACTIVE server — the one the owner selected in the servers section. That
@@ -1,7 +1,7 @@
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { DATA_PATH } from '../../data-path';
import { ANTHROPIC_PROXY_URL } from '../../officer-url.mjs';
import { DATA_PATH } from '@@/data-path';
import { ANTHROPIC_PROXY_URL } from '@@/officer-url.mjs';
// One-shot model calls, for sidecar features that need a sentence of reasoning rather than an agent.
//
@@ -1,4 +1,4 @@
import type { HeadscaleServerCredentials } from 'officerdb';
import type { HeadscaleServerCredentials } from '../db/queries';
// The Headscale admin API call layer. Every upstream request in this sidecar goes through here, so the
// wire-level quirks are handled once:
@@ -1,4 +1,4 @@
import { getActiveHeadscaleCredentials, type HeadscaleServerCredentials } from 'officerdb';
import { getActiveHeadscaleCredentials, type HeadscaleServerCredentials } from '../db/queries';
import { badRequest, methodNotAllowed, notFound, type OfficerContext } from './routes';
// The Officer Companion API — a small service deployed NEXT TO each Headscale server that answers what the
@@ -1,6 +1,6 @@
import type { OfficerContext } from './routes';
import type { OfficerUser } from './normalize';
import { getActiveHeadscaleCredentials } from 'officerdb';
import { getActiveHeadscaleCredentials } from '../db/queries';
import { badRequest, methodNotAllowed, readJson } from './routes';
import { createClient, type HeadscaleClient } from './client';
import { arrayField, toUser } from './normalize';
@@ -9,7 +9,8 @@ import { handleInvitesRoute } from './invites';
// Device enrolment — POST /_officer/enroll. The mobile app's one-tap join: it turns an authenticated
// Officer session into a short-lived, single-use pre-auth key, so nobody pastes a key by hand.
//
// THIS USED TO LIVE IN THE PLATFORM. `src/servers/api/vpn/router.ts` read HEADSCALE_URL, HEADSCALE_API_KEY
// THIS USED TO LIVE IN THE PLATFORM. `src/servers/api/vpn/router.ts` (deleted 2026-08-14) read
// HEADSCALE_URL, HEADSCALE_API_KEY
// and HEADSCALE_USER straight from the host env — three globals that could only ever describe ONE server,
// while this sidecar already kept a registry of many. Worse, the two credential vars were removed at some
// point and nobody noticed: the route had been answering 503 to every enrolment attempt, because it checks
@@ -1,8 +1,8 @@
import type { SidecarCommand, SidecarEvent } from '../protocol';
import { createSidecarConnector } from '../connect';
import type { SidecarCommand, SidecarEvent } from '@@/sidecar/protocol';
import { createSidecarConnector } from '@@/sidecar/connect';
import { handleOfficerRoute } from './routes';
import { MIN_VERSION_LABEL } from './version';
import { API_URL } from '../../officer-url.mjs';
import { API_URL } from '@@/officer-url.mjs';
// The officer-headscale sidecar. Owns the whole Headscale contract for Officer: the registered servers and
// their admin API keys, the >=0.29 version floor, and every multi-call composition the UI needs. The platform
@@ -47,7 +47,11 @@ import { API_URL } from '../../officer-url.mjs';
// DELETE /_officer/keys/:id delete outright
// POST /_officer/enroll {userId?} → {controlUrl, authKey} — a single-use 10-minute key
// for a joining device. userId is only required when the server
// has more than one user; reached via /api/vpn/enroll.
// has more than one user.
// NO CALLER since 2026-08-14: its only door was /api/vpn/enroll,
// which is deleted. Kept because it is the handler a route under
// /api/offscale would reuse, and because `/enroll/invites` — which
// IS live — dispatches through the same function.
// anything else 404
//
// There is deliberately NO transparent /api/v1/* passthrough. Headscale's REST shape changed repeatedly
@@ -55,7 +59,6 @@ import { API_URL } from '../../officer-url.mjs';
// — the mistake the Soulseek panels made with 37 raw upstream calls. Every quirk is absorbed here.
// ─────────────────────────────────────────────────────────────────────────────────────────────────
/** Grab an ephemeral free port by briefly binding one and releasing it. */
function getFreePort(): number {
const probe = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') });
@@ -1,4 +1,4 @@
import type { HeadscaleServerCredentials } from 'officerdb';
import type { HeadscaleServerCredentials } from '../db/queries';
import { badRequest, methodNotAllowed, notFound, readJson, type OfficerContext } from './routes';
import { activeCreds, callCompanion, readBody, unavailable } from './companion';
@@ -7,7 +7,7 @@ import {
deleteHeadscaleServer,
getHeadscaleCredentials,
recordHeadscaleProbe,
} from 'officerdb';
} from '../db/queries';
import { createClient, HeadscaleError } from './client';
import { probeVersion, MIN_VERSION_LABEL } from './version';
import { badRequest, notFound, methodNotAllowed } from './routes';
@@ -3,7 +3,7 @@ import { Link } from 'react-router';
import { Loader2, TerminalSquare } from 'lucide-react';
import { headscaleSectionPath } from './shared';
import { useHeadscaleServers } from './useHeadscaleServers';
import { TerminalView } from '../Terminal/Terminal';
import { TerminalView } from 'officerdev';
import { Button } from './Cards';
// A shell on the machine behind the active Headscale server — the escape hatch for everything the API cannot
@@ -9,6 +9,7 @@ import { headscaleErrorMessage } from './useHeadscaleServers';
import { fullDate, timeAgo, timeUntil } from './format';
import { Badge, Button, Card, Dot, ErrorNote, Field } from './Cards';
import { EmptyBody, ViewShell } from './ViewShell';
import { copyToClipboard } from 'helpers/clipboard';
// Device invites — the admin surface of COMMS/OFFSCALE_INVITE_ENROLLMENT.md §5.
//
@@ -44,7 +45,7 @@ const TTL_OPTIONS = [
const CopyButton = ({ value, label }: { value: string; label: string }) => {
const [done, setDone] = useState(false);
const copy = () => {
void navigator.clipboard?.writeText(value);
void copyToClipboard(value);
setDone(true);
window.setTimeout(() => setDone(false), 1500);
};
@@ -6,6 +6,7 @@ import { useHeadscaleServers, headscaleErrorMessage } from './useHeadscaleServer
import { timeAgo, timeUntil, fullDate } from './format';
import { Card, Button, Field, Badge, Dot, ErrorNote } from './Cards';
import { ViewShell, EmptyBody } from './ViewShell';
import { copyToClipboard } from 'helpers/clipboard';
// Pre-auth keys — the tokens a machine presents to join the tailnet.
//
@@ -28,7 +29,7 @@ const STATUS_TONE = { active: 'ok', used: 'idle', expired: 'bad' } as const;
const CopyButton = ({ value, label }: { value: string; label: string }) => {
const [done, setDone] = useState(false);
const copy = () => {
void navigator.clipboard?.writeText(value);
void copyToClipboard(value);
setDone(true);
window.setTimeout(() => setDone(false), 1500);
};
@@ -20,6 +20,7 @@ import { headscaleErrorMessage } from './useHeadscaleServers';
import { timeAgo, timeUntil, fullDate } from './format';
import { Card, Button, Dot, Badge, ErrorNote } from './Cards';
import { ViewShell, EmptyBody } from './ViewShell';
import { copyToClipboard } from 'helpers/clipboard';
// The nodes section — the machines in the tailnet.
//
@@ -28,7 +29,7 @@ import { ViewShell, EmptyBody } from './ViewShell';
// rather than shown as the bare 0.0.0.0/0 that it advertises. Approving one route sends the whole approved
// set upstream; the sidecar does that read-modify-write so two panels can't clobber each other's sets.
const copy = (text: string) => void navigator.clipboard?.writeText(text);
const copy = (text: string) => void copyToClipboard(text);
type RouteRowProps = { route: string; approved: boolean; busy: boolean; onToggle: (approved: boolean) => void };
@@ -2,7 +2,7 @@ import type { LayoutNode } from 'officerdev';
export const defaultLayout: LayoutNode = {
type: 'group',
id: 'headscale-root',
id: 'offscale-root',
direction: 'horizontal',
children: [
{
@@ -1,4 +1,4 @@
import type { AppRegistryMeta } from '../../AppRegistry';
import type { AppRegistryMeta } from 'officerdev';
import { PanelLeft, LayoutGrid, Network } from 'lucide-react';
import { HeadscaleNav } from './HeadscaleNav';
import { HeadscaleServerPicker } from './HeadscaleServerPicker';
@@ -7,7 +7,7 @@ import type { CompanionAction, CompanionActionResult, CompanionHealthResult, Com
// because the companion authenticates with the Headscale admin key — which is encrypted in Postgres and
// decryptable only there. The browser never sees it and never talks to the companion directly.
const BASE = '/headscale/_officer/companion';
const BASE = '/offscale/_officer/companion';
const HEALTH_KEY = ['headscale', 'companion', 'health'] as const;
/**
@@ -24,28 +24,26 @@ export function useHeadscaleNodes() {
const query = useQuery({
queryKey: NODES_KEY,
queryFn: () => get<{ nodes: HeadscaleNode[] }>('/headscale/_officer/nodes'),
queryFn: () => get<{ nodes: HeadscaleNode[] }>('/offscale/_officer/nodes'),
// Online/lastSeen go stale fast, and this is a screen you sit on while waiting for a machine to join.
refetchInterval: 20_000,
staleTime: 10_000,
});
const rename = useMutation({
mutationFn: ({ id, name }: { id: string; name: string }) =>
post(`/headscale/_officer/nodes/${id}/rename`, { name }),
mutationFn: ({ id, name }: { id: string; name: string }) => post(`/offscale/_officer/nodes/${id}/rename`, { name }),
onSuccess: invalidate,
});
const setTags = useMutation({
mutationFn: ({ id, tags }: { id: string; tags: string[] }) =>
post(`/headscale/_officer/nodes/${id}/tags`, { tags }),
mutationFn: ({ id, tags }: { id: string; tags: string[] }) => post(`/offscale/_officer/nodes/${id}/tags`, { tags }),
onSuccess: invalidate,
});
// Re-owning a node. Takes the target user's id, not its name — Headscale's ids are uint64-as-string.
const moveToUser = useMutation({
mutationFn: ({ id, userId }: { id: string; userId: string }) =>
post(`/headscale/_officer/nodes/${id}/user`, { userId }),
post(`/offscale/_officer/nodes/${id}/user`, { userId }),
onSuccess: invalidate,
});
@@ -53,17 +51,17 @@ export function useHeadscaleNodes() {
// because Headscale's approve_routes replaces the whole set.
const toggleRoute = useMutation({
mutationFn: ({ id, route, approved }: { id: string; route: string; approved: boolean }) =>
post(`/headscale/_officer/nodes/${id}/routes`, { route, approved }),
post(`/offscale/_officer/nodes/${id}/routes`, { route, approved }),
onSuccess: invalidate,
});
const expire = useMutation({
mutationFn: (id: string) => post(`/headscale/_officer/nodes/${id}/expire`),
mutationFn: (id: string) => post(`/offscale/_officer/nodes/${id}/expire`),
onSuccess: invalidate,
});
const remove = useMutation({
mutationFn: (id: string) => del(`/headscale/_officer/nodes/${id}`),
mutationFn: (id: string) => del(`/offscale/_officer/nodes/${id}`),
onSuccess: invalidate,
});
@@ -87,24 +85,23 @@ export function useHeadscaleUsers() {
const query = useQuery({
queryKey: USERS_KEY,
queryFn: () => get<{ users: HeadscaleUserWithCounts[] }>('/headscale/_officer/users'),
queryFn: () => get<{ users: HeadscaleUserWithCounts[] }>('/offscale/_officer/users'),
staleTime: 30_000,
});
const create = useMutation({
mutationFn: (input: { name: string; displayName?: string; email?: string }) =>
post('/headscale/_officer/users', input),
post('/offscale/_officer/users', input),
onSuccess: invalidate,
});
const rename = useMutation({
mutationFn: ({ id, name }: { id: string; name: string }) =>
post(`/headscale/_officer/users/${id}/rename`, { name }),
mutationFn: ({ id, name }: { id: string; name: string }) => post(`/offscale/_officer/users/${id}/rename`, { name }),
onSuccess: invalidate,
});
const remove = useMutation({
mutationFn: (id: string) => del(`/headscale/_officer/users/${id}`),
mutationFn: (id: string) => del(`/offscale/_officer/users/${id}`),
onSuccess: invalidate,
});
@@ -133,7 +130,7 @@ export function useHeadscaleKeys() {
const query = useQuery({
queryKey: KEYS_KEY,
queryFn: () => get<{ keys: HeadscalePreAuthKey[] }>('/headscale/_officer/keys'),
queryFn: () => get<{ keys: HeadscalePreAuthKey[] }>('/offscale/_officer/keys'),
staleTime: 30_000,
});
@@ -141,17 +138,17 @@ export function useHeadscaleKeys() {
// (not merged into the list cache) so the view can show it once and deliberately drop it.
const create = useMutation({
mutationFn: (input: CreateKeyInput) =>
post<{ key: HeadscalePreAuthKey; secretShownOnce: boolean }>('/headscale/_officer/keys', input),
post<{ key: HeadscalePreAuthKey; secretShownOnce: boolean }>('/offscale/_officer/keys', input),
onSuccess: invalidate,
});
const expire = useMutation({
mutationFn: (id: string) => post(`/headscale/_officer/keys/${id}/expire`),
mutationFn: (id: string) => post(`/offscale/_officer/keys/${id}/expire`),
onSuccess: invalidate,
});
const remove = useMutation({
mutationFn: (id: string) => del(`/headscale/_officer/keys/${id}`),
mutationFn: (id: string) => del(`/offscale/_officer/keys/${id}`),
onSuccess: invalidate,
});
@@ -10,7 +10,7 @@ import type { HeadscaleInviteCreated, InviteCreateInput, InviteCreateResult, Inv
// claim link, and a cache is a place things persist: the view keeps it in component state, shows it once and
// drops it. The list is refetched instead, which returns the same invite without its token.
const BASE = '/headscale/_officer/enroll/invites';
const BASE = '/offscale/_officer/enroll/invites';
const INVITES_KEY = ['headscale', 'invites'] as const;
const EMPTY: InvitesListResult = { available: true, invites: [] };
@@ -8,7 +8,7 @@ import { POLICY_READ_ONLY, POLICY_REJECTED } from './shared';
// "your document is wrong, here is where" versus "this server does not accept written policies at all".
const POLICY_KEY = ['headscale', 'policy'] as const;
const PATH = '/headscale/_officer/policy';
const PATH = '/offscale/_officer/policy';
/** What a rejected save means. `rejected` carries Headscale's own message; `readOnly` ends the editing. */
export type PolicySaveFailure = { kind: 'rejected' | 'readOnly' | 'unknown'; message: string };

Some files were not shown because too many files have changed in this diff Show More