28 Commits
Author SHA1 Message Date
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
49 changed files with 2784 additions and 361 deletions
+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.
+689
View File
@@ -0,0 +1,689 @@
# Offscale — the first real plugin
**Status: LIVE DOCUMENT, opened 2026-08-14.** Decisions and findings from the session that started the
plugin system. Correct it in place; it is meant to be edited, not archived.
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;
```
### 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, as of 2026-08-14
The plugin system works end to end for a plugin that has an `api/router.ts`. Verified against a running
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 routes throughout 200
```
| Piece | Where |
| ------------------------------------------- | --------------------------------------------- |
| Manifest type, `mountPrefix`, validation | `servers/plugins/manifest.ts` |
| Discovery by convention | `servers/plugins/discover.ts` |
| Disk ⋈ database, and the rebuild | `servers/plugins/mount.ts` |
| `buildHonoApp` / `rebuildHonoApp` | `servers/hono.ts` |
| The closure that makes the swap take effect | `server.tsx`, the `/api/*` route |
| Install state | `plugin_installs` (`officer_db/src/plugins/`) |
| The four verbs | `servers/api/plugins/router.ts`, owner-only |
| The screen | `/plugins` — two panels, `?selected=` |
| The reference plugin | `plugins/example/` — meant to be read |
### Not wired yet
- **The schema push.** A plugin with `db/schema.ts` installs, but its tables are not created. Marked
`[open]` in the router.
- **The sidecar's PM2 entry.** A plugin with `sidecar/` installs, but no process starts. This is the same
hole `app-store/pm2.ts:23-29` documents for the app store, and it is the next thing to build.
- **Websocket providers.** `server.reload({ routes })` is proven but not called; Bun's route table is
still the six hardcoded providers.
- **Totality across plugin routes.** `PROTECTED_API_PREFIXES` remains the core list, so plugin mounts are
not covered by the boot check — and the check is reading the wrong list anyway (see below). The
assertion wants moving into `buildHonoApp`, which is now the single place routes are mounted.
### Deliberately not done
**Offscale is not extracted.** The infrastructure is ready for it, but moving it means deleting working
code across ~50 files, and that should happen with someone watching rather than unattended.
---
## 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.
+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 }));
+29
View File
@@ -0,0 +1,29 @@
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',
// Empty is meaningful: this plugin gates nothing of its own and is reachable by anyone who can reach
// the platform. A plugin with a surface worth protecting declares a permission here instead.
permissions: [],
};
+5 -4
View File
@@ -96,23 +96,24 @@ pkgs_core() {
# separate decision from removing the tool that wanted it.
echo curl ca-certificates gnupg git jq unzip \
apt-transport-https lsb-release software-properties-common \
wget zip build-essential python3 btop htop tree tmux ripgrep fd-find net-tools eza \
wget zip brotli build-essential python3 btop htop tree tmux ripgrep fd-find net-tools eza \
fail2ban unattended-upgrades
;;
pacman)
echo curl ca-certificates gnupg git jq unzip \
wget zip base-devel python btop htop tree tmux ripgrep fd net-tools eza \
wget zip brotli base-devel python btop htop tree tmux ripgrep fd net-tools eza \
fail2ban
;;
dnf)
echo curl ca-certificates gnupg2 git jq unzip \
wget zip python3 btop htop tree tmux ripgrep fd-find net-tools eza \
wget zip brotli python3 btop htop tree tmux ripgrep fd-find net-tools eza \
fail2ban
;;
brew)
# curl, unzip and the TLS roots ship with macOS; the compilers come from
# the Xcode command line tools, which is not a formula — see xcode_clt_*.
echo gnupg git jq wget btop htop tree ripgrep fd eza
# brotli is here because macOS ships the library but not the CLI.
echo gnupg git jq wget brotli btop htop tree ripgrep fd eza
;;
esac
}
+5
View File
@@ -446,6 +446,11 @@ if ! skip; then
echo " network: ${OFFICER_NETWORK} (already there)"
fi
# The client goes on the HOST, before any of the container work, because it is the half
# that is not in the container. A member has their own Postgres role and no access to the
# owner's Docker socket, so `docker exec … psql` is the owner's tool, not theirs.
install_pg_client || ERRORS+=("psql: client not installed — members have no Postgres CLI")
echo " compose file: $(pg_compose_exists && echo "$(pg_compose_file)" || echo 'not written yet')"
echo " container: $(pg_container_running && echo "${PG_CONTAINER} running" || echo 'not running')"
echo " port ${PG_PORT}: $(pg_port_in_use && echo 'something is listening' || echo 'free')"
@@ -39,6 +39,73 @@ PG_DATABASE="${PG_DATABASE:-officer}"
PG_CONTAINER="${PG_CONTAINER:-officer-postgres}"
PG_PORT="${PG_PORT:-5432}"
# ── The CLIENT, on the host, matching the server in the container ──
#
# `psql` was on no install. The server runs in Docker, so nothing ever put a client on the
# host, and `docker exec officer-postgres psql` is not a substitute for a member: they have
# their own Postgres role (`provisionPostgresRole` for Developers) and no access to the
# owner's Docker socket.
#
# The version is derived from PG_IMAGE rather than typed again, because the pairing is not
# cosmetic: **pg_dump refuses a server newer than itself** ("server version 18.6, pg_dump
# version 16.x — aborting"). 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 lives
# beside the server definition rather than in machine-setup's package list — one constant,
# one place to bump.
pg_client_major() { sed -E 's/^postgres:([0-9]+).*/\1/' <<<"$PG_IMAGE"; }
pg_client_installed() {
command -v psql >/dev/null 2>&1 && [[ "$(psql --version | grep -oE '[0-9]+' | head -1)" == "$(pg_client_major)" ]]
}
# PGDG, added the same way docker.sh adds Docker's: key to its own file, one sources.list.d
# entry, no add-apt-repository. Non-fatal — an install without psql is a working platform,
# just a more annoying one to operate.
install_pg_client() {
local major codename
major="$(pg_client_major)"
[[ -n "$major" ]] || {
warn "could not read a major version out of PG_IMAGE=${PG_IMAGE} — skipping the client"
return 1
}
if pg_client_installed; then
ok "psql ${major} already installed"
return 0
fi
codename="$(. /etc/os-release && echo "${VERSION_CODENAME:-}")"
[[ -n "$codename" ]] || {
warn "could not work out this release's codename — cannot add the PostgreSQL repository"
return 1
}
install -d -m 0755 /usr/share/postgresql-common/pgdg
curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc \
-o /usr/share/postgresql-common/pgdg/apt.postgresql.org.asc || {
warn "could not fetch the PostgreSQL signing key"
return 1
}
chmod a+r /usr/share/postgresql-common/pgdg/apt.postgresql.org.asc
echo "deb [signed-by=/usr/share/postgresql-common/pgdg/apt.postgresql.org.asc] https://apt.postgresql.org/pub/repos/apt ${codename}-pgdg main" \
>/etc/apt/sources.list.d/pgdg.list
DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get update -qq || true
DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get install -y -qq "postgresql-client-${major}" || {
warn "postgresql-client-${major} did not install"
return 1
}
# The exit status is not the gate — same lesson as rootless Docker and the claude CLI: what
# matters is whether the binary is there AND is the version we asked for, because apt can
# succeed while holding an older client back.
pg_client_installed || {
warn "psql is not version ${major} after installing — check: apt-cache policy postgresql-client-${major}"
return 1
}
ok "psql $(psql --version | grep -oE '[0-9]+\.[0-9]+' | head -1) installed for every account on this machine"
}
docker_network_exists() { docker network inspect "$OFFICER_NETWORK" &>/dev/null; }
ensure_docker_network() {
docker_network_exists && return 1
@@ -44,7 +44,6 @@ CORE_PROCESSES=(
"officer-claude-code|bun|run src/servers/sidecar/claude/user-instance.ts"
"officer-opencode|bun|run src/servers/sidecar/opencode/index.ts"
"officer-pty|node|src/servers/sidecar/pty/index.mjs"
"officer-headscale|bun|run src/servers/sidecar/headscale/index.ts"
)
write_ecosystem() {
+1
View File
@@ -64,6 +64,7 @@ export function App() {
<Route path="/photos" element={<Dashboard.PhotosScreen />} />
<Route path="/photos/:section" element={<Dashboard.PhotosScreen />} />
<Route path="/app-store" element={<Dashboard.AppStoreScreen />} />
<Route path="/plugins" element={<Dashboard.PluginsScreen />} />
<Route path="/jellyfin" element={<Dashboard.JellyfinScreen />} />
<Route path="/jellyfin/:section" element={<Dashboard.JellyfinScreen />} />
<Route path="/transmission" element={<Dashboard.TransmissionScreen />} />
@@ -149,6 +149,7 @@ import {
Clapperboard,
GitBranch,
Store,
Puzzle,
} from 'lucide-react';
/**
@@ -181,6 +182,8 @@ export const CORE_DOCK_ITEMS: DockItem[] = [
// Core by necessity: the store is how every other feature arrives, so it can never be one of the
// things that disappears when uninstalled.
{ label: 'App store', to: '/app-store', icon: Store, color: '#64748b' },
// Core, not contributed by a plugin: this is the screen that installs them, so it cannot arrive with one.
{ label: 'Plugins', to: '/plugins', icon: Puzzle, color: '#94a3b8' },
];
/**
@@ -0,0 +1,25 @@
import type { LayoutNode } from 'officerdev';
import { WorkspaceView } from 'officerdev';
import { useDashboardState } from 'state/useDashboardState';
import { defaultLayout } from './defaultLayout';
// /plugins — what is in the tree, what is installed, and the four verbs that change it.
//
// Owner-only, and gated server-side: every route under /api/plugins refuses a non-owner before reaching a
// handler. This screen is the courtesy half of that.
//
// Not the app store. That installs sidecars from a catalogue, provisioning containers and asking
// questions; this installs plugins from `platform/plugins/`, and asks nothing.
export const PluginsScreen = () => {
const workspace = useDashboardState<LayoutNode>('screens/plugins', defaultLayout);
return (
<div className="h-full w-full pt-2">
<WorkspaceView
workspace={workspace}
locked
appTypes={{ allowed: ['plugins-list', 'plugin-detail'], fallback: 'plugin-detail' }}
/>
</div>
);
};
@@ -0,0 +1,14 @@
import type { LayoutNode } from 'officerdev';
// List left, detail right — a master list with a live preview, which is why the selection is `?selected=`
// rather than a `/plugins/:appName` route: linking rows to the detail route would make it the whole page
// and destroy the side-by-side. See docs/navigation-audit.md.
export const defaultLayout: LayoutNode = {
type: 'group',
id: 'plugins-root',
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: 'plugins-list', appType: 'plugins-list' }, size: 32 },
{ node: { type: 'panel', id: 'plugin-detail', appType: 'plugin-detail' }, size: 68 },
],
};
@@ -0,0 +1 @@
export * from './PluginsScreen';
@@ -1,4 +1,5 @@
export * from './AppStore';
export * from './Plugins';
export * from './Layout';
export * from './Home';
export * from './PasskeyGate';
@@ -23,6 +23,7 @@ const RULES: TitleRule[] = [
{ match: (p) => p.startsWith('/contacts'), title: 'Contacts' },
{ match: (p) => p.startsWith('/music'), title: 'Music' },
{ match: (p) => p.startsWith('/app-store'), title: 'App store' },
{ match: (p) => p.startsWith('/plugins'), title: 'Plugins' },
{ match: (p) => p.startsWith('/photos'), title: 'Photos' },
{ match: (p) => p.startsWith('/jellyfin'), title: 'Video' },
{ match: (p) => p.startsWith('/soulseek'), title: 'Soulseek' },
+1
View File
@@ -29,6 +29,7 @@ export * as schema from './schema';
export * from './agent-panels';
export * from './api-keys';
export * from './app-store';
export * from './plugins';
export * from './auth';
export * from './capabilities';
export * from './chat-events';
@@ -0,0 +1,2 @@
export * from './schema';
export * from './queries';
@@ -0,0 +1,49 @@
import { eq } from 'drizzle-orm';
import { db } from '../db';
import { pluginInstalls } from './schema';
export type PluginInstall = typeof pluginInstalls.$inferSelect;
/** Every installed plugin, oldest first so the list is stable across renders. */
export async function listPluginInstalls(): Promise<PluginInstall[]> {
return db.select().from(pluginInstalls).orderBy(pluginInstalls.appName);
}
export async function getPluginInstall(appName: string): Promise<PluginInstall | null> {
const [row] = await db.select().from(pluginInstalls).where(eq(pluginInstalls.appName, appName)).limit(1);
return row ?? null;
}
/**
* Record an install, or update the version of one already there.
*
* Upsert rather than insert, because re-installing is how a plugin is upgraded: the code on disk moved,
* and the row should follow it rather than refuse. `enabled` is deliberately NOT touched on the update
* path — re-installing a plugin the owner had disabled must not silently switch it back on.
*/
export async function recordPluginInstall(appName: string, version: string): Promise<PluginInstall> {
const [row] = await db
.insert(pluginInstalls)
.values({ appName, version })
.onConflictDoUpdate({
target: pluginInstalls.appName,
set: { version, updatedAt: new Date() },
})
.returning();
return row!;
}
export async function setPluginEnabled(appName: string, enabled: boolean): Promise<PluginInstall | null> {
const [row] = await db
.update(pluginInstalls)
.set({ enabled, updatedAt: new Date() })
.where(eq(pluginInstalls.appName, appName))
.returning();
return row ?? null;
}
/** Forget the install. Drops no tables and deletes no data — see the note in schema.ts. */
export async function removePluginInstall(appName: string): Promise<boolean> {
const rows = await db.delete(pluginInstalls).where(eq(pluginInstalls.appName, appName)).returning();
return rows.length > 0;
}
@@ -0,0 +1,58 @@
import { pgTable, serial, text, boolean, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
// Which plugins are installed on this machine, and whether they should be mounted.
//
// ── Why a row is needed at all, when the code is already on disk ──
//
// Plugins live in the repository (`platform/plugins/<app-name>/`), so PRESENCE is not installation. A
// developer working on a plugin has the directory there and has not installed anything; a plugin that
// ships in a checkout should not mount itself because someone cloned it. The directory answers "what
// could run here", this table answers "what does".
//
// ── Separate from `sidecar_installs`, deliberately ──
//
// That table belongs to the app store's model, where an install 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: put the code there, push its schema, start its sidecar, mount its
// routes. Reusing the table would have meant a `mode` that lies about every plugin. The two models
// coexist until the app store is rebuilt on this one.
//
// ── No userId, for the same reason as `sidecar_installs` ──
//
// installed server-level, owner-only — this row
// permitted per role — role_capabilities
// configured per user — the plugin's own tables
//
// ── `enabled` is not `installed` ──
//
// Installed means the schema is pushed and the code is ready. Enabled means it should be mounted and its
// sidecar running. Disabling is the reversible middle: routes come down, the process stops, and every
// table and row it owns survives untouched. Uninstalling drops the row and unmounts, and still does not
// delete data — dropping a plugin's tables is a separate, deliberate act with the cost shown.
export const pluginInstalls = pgTable(
'plugin_installs',
{
id: serial('id').primaryKey(),
/**
* The plugin's app name — its directory, its route segment and its table prefix, all the same string.
* Text rather than an enum: adding a plugin must never be a schema change.
*/
appName: text('app_name').notNull(),
/**
* The manifest version at the moment it was installed.
*
* Kept so an upgrade has something to compare against, and so "installed" can be told from "installed,
* then the code on disk moved underneath it" — which is the normal state on a developer's machine and
* a thing worth being able to see rather than infer.
*/
version: text('version').notNull(),
enabled: boolean('enabled').notNull().default(true),
installedAt: timestamp('installed_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [
// One row per plugin, machine-wide. uniqueIndex rather than unique() — see databases/CLAUDE.md on
// drizzle-kit re-creating named composite constraints on every push.
uniqueIndex('uq_plugin_installs_app_name').on(t.appName),
],
);
+1
View File
@@ -38,6 +38,7 @@ export * from './headscale/schema'; // headscale_servers
// The app store itself, and the credentials it stores for what it installs. `app-store/effects.ts`
// reads service_connections, so this is core however few plugins are installed.
export * from './app-store/schema'; // sidecar_installs
export * from './plugins/schema'; // plugin_installs — core: the plugin system is the platform's own
export * from './service-connections/schema'; // service_connections
// ── Plugins — uncomment when the plugin is installed ─────────────────────────────────────────────
+7 -2
View File
@@ -24,7 +24,6 @@ import './servers/api/chat/opencode/sidecar-server'; // subscribe to the opencod
import type { SidecarRegistration } from './servers/sidecar/registration-protocol';
import { toShellUsername } from './servers/data-path';
// Build static file routes from public/
const publicRoutes: Record<string, (req: Request) => Response> = {};
for await (const file of new Bun.Glob('**').scan({ cwd: './public' })) {
@@ -319,7 +318,13 @@ const server = serve({
'/': officerWeb,
'/*': officerWeb,
'/api': honoServer.fetch,
'/api/*': honoServer.fetch,
// A CLOSURE, deliberately, and not the bound `honoServer.fetch`.
//
// Installing a plugin swaps the whole Hono app (`rebuildHonoApp` — Hono cannot add routes to a live
// app, and cannot remove one at all). The bound method would capture whichever app existed when
// `serve()` ran, so every rebuild after boot would be invisible and an install would silently do
// nothing. Reading `honoServer` per request is what makes the reassignment the swap.
'/api/*': (req: Request, server: unknown) => honoServer.fetch(req, server),
},
websocket: {
+7 -3
View File
@@ -3,7 +3,7 @@ import { randomUUID } from 'node:crypto';
import { basename, dirname, join } from 'node:path';
import type { TurnMessage } from '../chat/types';
import { sendClaudeCodeStreaming } from '../../channels/send-claude-code';
import { renameClaudeSession } from '../chat/claude-sessions';
import { renameClaudeSession, type ChatIdentity } from '../chat/claude-sessions';
import { getAgentRunsDir, getOwnerHomeDir } from '../../data-path';
import { getAgentByDirName, DEFAULT_AGENT_MODEL, type AgentRecord } from './agent-files';
import { logger } from '../chat/logger';
@@ -87,7 +87,7 @@ export function buildAgentPrompt(agent: AgentRecord, inputs: Record<string, unkn
* which is fine: while a run is live you find it as the newest entry in the agent's project group.
*/
function titleRun(
who: { email: string; home: string },
who: ChatIdentity,
cwd: string,
claudeSessionId: string,
agentName: string,
@@ -172,7 +172,11 @@ export async function startAgentRun(params: StartAgentRunParams): Promise<StartA
if (msg.claudeSessionId) {
run.claudeSessionId = msg.claudeSessionId;
titleRun(
{ email: params.user.email, home: homeDir },
// `osUser: null` and `isOwner: true` both track `homeDir` above: it is `getOwnerHomeDir`, which
// discards the email it is given, so an agent run is always the owner's — its transcript is theirs
// and readable directly. `agents` is an `execution` capability, so no other account reaches this.
// If agent runs ever reach members, this and line 134 have to move together.
{ email: params.user.email, home: homeDir, osUser: null, isOwner: true },
cwd,
msg.claudeSessionId,
agent.name || agent.dirName,
+36 -6
View File
@@ -37,14 +37,31 @@ import { readSttConfig } from '../server-settings/stt';
* `resolveTurnIdentity` refuses: there is no
* safe home to substitute, and the owner's is the one wrong answer.
*
* Unreachable by a member today — the router refuses non-owners above — so this is the path being made correct
* before it is opened, not a live fix.
* Reachable by a member since 2026-08-12 — see the note below on what replaced the wholesale refusal that
* used to stand at the top of this router.
*/
async function chatIdentity(user: { id: number; email: string }): Promise<ChatIdentity> {
const resolved = await resolveHomeDir(user.id);
if (!resolved.ok) throw errors.FORBIDDEN(resolved.reason);
return { email: user.email, home: resolved.home };
return { email: user.email, home: resolved.home, osUser: resolved.osUser, isOwner: resolved.isOwner };
}
// ── OpenCode is owner-only, temporarily ──
//
// The Claude harness earned its way to members: the turn runs as their Linux account, the credential and
// transcripts are theirs, and every sidecar command refuses a session belonging to someone else. NONE of that
// is true of OpenCode. One `opencode serve` runs as the SERVICE user for everyone, `sendOpenCodeStreaming`
// accepts `userId`/`email`/`username` and forwards none of them, and its session store has no per-user
// scoping at all — `loadOpenCodeSession(id)` takes an id and no identity.
//
// Two consequences, both reachable by any account holding the `chat` grant, which every role has by default:
// a turn ran in the OWNER'S home as the owner, and any session on the box could be read, renamed or deleted
// by id. `handleOpenCodeChat` carried a comment calling itself owner-only; nothing enforced it.
//
// So this is a stopgap, not a design: `who.isOwner` applied at every door below, until OpenCode carries an
// identity the way `spawnClaudeAsMember` does. Restrict here rather than at the capability layer because
// `chat` is one capability covering both harnesses, and splitting it would strand the grants already issued.
// The matching refusal on the execution path is in `websocket.ts` → `handleChat`.
import { transcribeAudio } from '../stt/transcribe';
import { registerAgentPanelRoutes } from './agent-panels-routes';
@@ -88,7 +105,9 @@ chatRouter.get('/sessions', async (ctx) => {
const who = await chatIdentity(ctx.get('user'));
const cwd = cwdOf(ctx, who.home);
const claude = listClaudeSessions(who, cwd).map((s) => ({ ...s, harness: 'claude' as const }));
const opencode = await listOpenCodeSessions(cwd);
// OpenCode's store is shared and unscoped, so for anyone but the owner this list is other people's
// conversations. Empty rather than filtered: there is no per-user field to filter ON.
const opencode = who.isOwner ? await listOpenCodeSessions(cwd) : [];
const sessions = [...claude, ...opencode].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
return ctx.json({ sessions });
});
@@ -105,6 +124,9 @@ chatRouter.get('/sessions/:id', async (ctx) => {
const cwd = cwdOf(ctx, who.home);
// Fall back to a by-id scan when the (default) cwd doesn't hold it — a fresh /chat/<id> deep-link/refresh
// doesn't know the session's cwd. The returned detail carries the real cwd for the client to scope the UI.
// 404 rather than 403 on the OpenCode branch: a non-owner has no way to tell a session they may not read
// from one that does not exist, which is the honest answer when the store has no notion of whose it is.
if (isOpenCodeSessionId(id) && !who.isOwner) return ctx.text('Not found', 404);
const detail = isOpenCodeSessionId(id)
? await loadOpenCodeSession(id)
: (loadClaudeSession(who, cwd, id) ?? loadClaudeSessionById(who, id));
@@ -151,9 +173,11 @@ chatRouter.get('/live', async (ctx) => {
const email = who.email;
// Both harnesses, asked in parallel. Either failing contributes nothing rather than failing the panel:
// both registry calls swallow their errors and return [].
// The Claude call is already scoped by userId; the OpenCode one has no such argument, so it is asked only
// for the owner. A member's Live panel therefore shows their own turns and nothing else.
const [live, liveOpenCode] = await Promise.all([
sidecar.listLiveClaudeSessions(user.id),
sidecar.listLiveOpenCodeSessions(),
who.isOwner ? sidecar.listLiveOpenCodeSessions() : Promise.resolve([]),
]);
const sessions = live.map((session) => {
// Resolve by Claude's id, never by the session key — the key is officer's handle and the transcript
@@ -213,6 +237,7 @@ chatRouter.delete('/sessions/:id', async (ctx) => {
const who = await chatIdentity(ctx.get('user'));
const id = ctx.req.param('id');
const cwd = cwdOf(ctx, who.home);
if (isOpenCodeSessionId(id) && !who.isOwner) return ctx.text('Not found', 404);
const ok = isOpenCodeSessionId(id) ? await deleteOpenCodeSession(id) : deleteClaudeSession(who, cwd, id);
if (!ok) return ctx.text('Not found', 404);
return ctx.json({ ok: true });
@@ -225,6 +250,7 @@ chatRouter.patch('/sessions/:id/title', async (ctx) => {
const cwd = cwdOf(ctx, who.home);
const { title } = await ctx.req.json<{ title?: string }>();
if (!title?.trim()) return ctx.text('title is required', 400);
if (isOpenCodeSessionId(id) && !who.isOwner) return ctx.text('Not found', 404);
const ok = isOpenCodeSessionId(id)
? await renameOpenCodeSession(id, title.trim())
: renameClaudeSession(who, cwd, id, title.trim());
@@ -246,8 +272,12 @@ chatRouter.get('/tasks/:id', async (ctx) => {
// GET /chat/models — Claude tiers only (the runner is the `claude` CLI).
chatRouter.get('/models', async (ctx: Context) => {
const who = await chatIdentity(ctx.get('user'));
try {
const models = await listChatModels();
const all = await listChatModels();
// Hiding these is a courtesy — the socket refuses them regardless — but offering a model that cannot run
// is how a member ends up reporting "chat is broken" for a choice the UI made available.
const models = who.isOwner ? all : all.filter((m) => m.provider === 'claude-code');
const providerNames: Record<string, string> = { 'claude-code': 'Claude Code', opencode: 'OpenCode Zen' };
return ctx.json({ models, providerNames, hostHome: process.env.HOME ?? '' });
} catch (err) {
+143 -149
View File
@@ -1,18 +1,15 @@
import {
readdirSync,
readFileSync,
existsSync,
statSync,
mkdirSync,
rmSync,
appendFileSync,
openSync,
readSync,
closeSync,
realpathSync,
} from 'node:fs';
import { realpathSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { DATA_PATH } from '../../data-path';
import {
appendTextAs,
listTranscriptsAs,
readHeadAs,
readTailAs,
readTextAs,
removeAs,
type AsUser,
} from '../../read-as-user';
// ── Claude session store (source of truth) ──
// The `claude` CLI persists every session as a JSONL transcript at
@@ -41,10 +38,50 @@ export type ChatIdentity = {
email: string;
/** From `resolveHomeDir`. Never `getOwnerHomeDir`, which ignores its argument. */
home: string;
/**
* Whose identity to read a member's transcripts as — `null` for the owner. From `resolveHomeDir`.
*
* This said locating them "never needed this: their directories are 775". There are no 775 directories on
* this path — `claude` creates `projects/` and every group at 700, which clamps the platform's ACL entry
* to nothing exactly as mode 600 does for the files. So `readdirSync`, `statSync` and unlink all fail too,
* and `existsSync` answers **false** rather than throwing, which is why it read as "no such session"
* everywhere instead of as an error. See `read-as-user.ts`; the listing goes through `listTranscriptsAs`.
*/
osUser: string | null;
/**
* From `resolveHomeDir`. Carried as its own fact rather than inferred from `osUser === null` — that
* equivalence holds today only because `resolveHomeDir` refuses a member without one, so reading it as
* "is the owner" would silently become wrong the moment that refusal is relaxed.
*
* Used to gate the OpenCode harness, which is owner-only until it carries an identity. See `chat.ts`.
*/
isOwner: boolean;
};
const claudeProjectsDir = (home: string): string => join(home, '.claude', 'projects');
/**
* Where a session's transcript actually is, or null.
*
* One listing as the transcripts' owner, then a lookup — never `existsSync` on a candidate path. For a
* member `existsSync` answers **false** on a file that is plainly there, because the group directory is
* mode 700 and the service user cannot traverse it, and every caller read that false as "no such session".
*
* `cwd` names the group to prefer, not the group to trust: a session's group and the caller's current one
* disagree routinely (a deep link has not resolved its group yet, or the list is showing another). Reads
* have always fallen back like this; writes did not, so delete and rename returned "not found" for a
* session that was on screen.
*/
function locateTranscript(who: ChatIdentity, sessionId: string, cwd?: string): string | null {
const projectsDir = claudeProjectsDir(who.home);
const files = listTranscriptsAs(who.osUser, projectsDir);
const preferred = cwd ? projectSlug(cwd) : null;
const hit =
(preferred && files.find((f) => f.id === sessionId && f.slug === preferred)) ||
files.find((f) => f.id === sessionId);
return hit ? join(projectsDir, hit.slug, `${sessionId}.jsonl`) : null;
}
/** Claude's folder name for a working directory. */
export const projectSlug = (cwd: string): string => cwd.replace(/[^a-zA-Z0-9]/g, '-');
@@ -126,22 +163,17 @@ type Entry = {
*/
const summaryCache = new Map<string, { mtimeMs: number; summary: TranscriptSummary }>();
function summarizeTranscript(filePath: string, id: string): TranscriptSummary | null {
let mtimeMs: number;
let mtime: string;
try {
const stat = statSync(filePath);
mtimeMs = stat.mtimeMs;
mtime = stat.mtime.toISOString();
} catch {
return null;
}
// `mtimeMs` is passed in rather than stat'ed here: `statSync` is EACCES on a member's transcript, and the
// listing that found the file already carries its mtime. Stat'ing again would be a second fork per file AND
// would fail for exactly the accounts this exists to serve.
function summarizeTranscript(osUser: AsUser, filePath: string, id: string, mtimeMs: number): TranscriptSummary | null {
const mtime = new Date(mtimeMs).toISOString();
const cached = summaryCache.get(filePath);
if (cached && cached.mtimeMs === mtimeMs) return cached.summary;
let raw: string;
try {
raw = readFileSync(filePath, 'utf-8');
raw = readTextAs(osUser, filePath);
} catch {
return null;
}
@@ -418,15 +450,31 @@ function userMessageFrom(text: string): ClaudeChatMessage | null {
export type ClaudeSessionDetail = { id: string; model: string; cwd: string; messages: ClaudeChatMessage[] };
/** Parse a session's JSONL transcript into a flat, display-ready message list. Claude is the source. */
function parseClaudeTranscript(filePath: string, sessionId: string, fallbackCwd = ''): ClaudeSessionDetail | null {
if (!existsSync(filePath)) return null;
function parseClaudeTranscript(
osUser: AsUser,
filePath: string,
sessionId: string,
fallbackCwd = '',
): ClaudeSessionDetail | null {
// No `existsSync` guard: it is redundant with the catch below (a missing file throws there just the same)
// and it is actively WRONG for a member, answering false on a transcript that exists — which is how a
// /chat/<id> deep link 404'd on a session the member was looking at.
const messages: ClaudeChatMessage[] = [];
const toolById = new Map<string, Extract<ClaudeChatMessage, { role: 'tool' }>>();
let model = '';
let sessionCwd = fallbackCwd;
for (const line of readFileSync(filePath, 'utf-8').split('\n')) {
// `existsSync` above passes for a member's transcript and the read still fails — the file is theirs at
// mode 600. That combination used to escape as a 500 from `GET /chat/sessions/:id`, because only the
// list path caught its read. "Not found" is the honest answer for a transcript we cannot open.
let raw: string;
try {
raw = readTextAs(osUser, filePath);
} catch {
return null;
}
for (const line of raw.split('\n')) {
if (!line.trim()) continue;
let entry: Entry & { message?: { role?: string; content?: unknown; model?: string } };
try {
@@ -526,7 +574,7 @@ function loadChainTranscript(who: ChatIdentity, detail: ClaudeSessionDetail): Cl
const dir = join(claudeProjectsDir(who.home), projectSlug(detail.cwd));
const earlier: ClaudeChatMessage[] = [];
for (const part of parts.slice(0, -1)) {
const segment = parseClaudeTranscript(join(dir, `${part.id}.jsonl`), part.id, detail.cwd);
const segment = parseClaudeTranscript(who.osUser, join(dir, `${part.id}.jsonl`), part.id, detail.cwd);
if (!segment) continue;
earlier.push(...segment.messages, { role: 'divider', sessionId: part.id });
}
@@ -537,6 +585,7 @@ function loadChainTranscript(who: ChatIdentity, detail: ClaudeSessionDetail): Cl
/** Load a session when its cwd (project group) is known. */
export function loadClaudeSession(who: ChatIdentity, cwd: string, sessionId: string): ClaudeSessionDetail | null {
const detail = parseClaudeTranscript(
who.osUser,
join(claudeProjectsDir(who.home), projectSlug(cwd), `${sessionId}.jsonl`),
sessionId,
cwd,
@@ -548,20 +597,10 @@ export function loadClaudeSession(who: ChatIdentity, cwd: string, sessionId: str
* refresh to /chat/<id>, when the cwd isn't known yet; the transcript records the real cwd, which the
* caller uses to scope the list + cwd picker. */
export function loadClaudeSessionById(who: ChatIdentity, sessionId: string): ClaudeSessionDetail | null {
const projectsDir = claudeProjectsDir(who.home);
let slugs: string[];
try {
slugs = readdirSync(projectsDir);
} catch {
return null;
}
for (const slug of slugs) {
const filePath = join(projectsDir, slug, `${sessionId}.jsonl`);
if (!existsSync(filePath)) continue;
const detail = parseClaudeTranscript(filePath, sessionId);
return detail && loadChainTranscript(who, detail);
}
return null;
const filePath = locateTranscript(who, sessionId);
if (!filePath) return null;
const detail = parseClaudeTranscript(who.osUser, filePath, sessionId);
return detail && loadChainTranscript(who, detail);
}
/**
@@ -571,23 +610,8 @@ export function loadClaudeSessionById(who: ChatIdentity, sessionId: string): Cla
* or a deep link hasn't resolved its group yet). Reads have always fallen back like this; writes did
* not, so delete and rename returned "not found" for a session that was plainly on screen.
*/
function findTranscript(who: ChatIdentity, cwd: string, sessionId: string): string | null {
const preferred = join(claudeProjectsDir(who.home), projectSlug(cwd), `${sessionId}.jsonl`);
if (existsSync(preferred)) return preferred;
const projectsDir = claudeProjectsDir(who.home);
let slugs: string[];
try {
slugs = readdirSync(projectsDir);
} catch {
return null;
}
for (const slug of slugs) {
const filePath = join(projectsDir, slug, `${sessionId}.jsonl`);
if (existsSync(filePath)) return filePath;
}
return null;
}
const findTranscript = (who: ChatIdentity, cwd: string, sessionId: string): string | null =>
locateTranscript(who, sessionId, cwd);
/**
* Delete a conversation by removing its transcript file — and, when it is a `/clear` chain, the files
@@ -601,13 +625,13 @@ export function deleteClaudeSession(who: ChatIdentity, cwd: string, sessionId: s
const filePath = findTranscript(who, cwd, sessionId);
if (!filePath) return false;
const ownCwd = firstCwd(filePath);
const ownCwd = firstCwd(who.osUser, filePath);
const ids = ownCwd ? chainFileIds(who, ownCwd, sessionId) : [sessionId];
// `removeAs`, not `rmSync`: unlinking needs `w`+`x` on the DIRECTORY, which the service user does not have
// on a member's group. `rmSync` guarded by `existsSync` therefore deleted nothing and still reported
// success — the conversation reappeared on the next refresh.
const dir = dirname(filePath);
for (const id of ids) {
const partPath = join(dir, `${id}.jsonl`);
if (existsSync(partPath)) rmSync(partPath);
}
for (const id of ids) removeAs(who.osUser, join(dir, `${id}.jsonl`));
return true;
}
@@ -622,7 +646,7 @@ export function renameClaudeSession(who: ChatIdentity, cwd: string, sessionId: s
// Attach the summary to the transcript's tip (the last entry carrying a uuid).
let leafUuid = sessionId;
const lines = readFileSync(filePath, 'utf-8').split('\n');
const lines = readTextAs(who.osUser, filePath).split('\n');
for (let i = lines.length - 1; i >= 0; i--) {
if (!lines[i]!.trim()) continue;
try {
@@ -636,7 +660,7 @@ export function renameClaudeSession(who: ChatIdentity, cwd: string, sessionId: s
}
}
appendFileSync(filePath, `${JSON.stringify({ type: 'summary', summary: title, leafUuid })}\n`);
appendTextAs(who.osUser, filePath, `${JSON.stringify({ type: 'summary', summary: title, leafUuid })}\n`);
return true;
}
@@ -689,21 +713,10 @@ function findTaskOutput(who: ChatIdentity, taskId: string): string | null {
}
/** The tail of a file, as text, without reading the whole thing. */
function tailFile(filePath: string, bytes: number): { text: string; truncated: boolean } {
const size = statSync(filePath).size;
const start = Math.max(0, size - bytes);
let fd: number | undefined;
try {
fd = openSync(filePath, 'r');
const buf = Buffer.alloc(size - start);
const n = readSync(fd, buf, 0, buf.length, start);
let text = buf.toString('utf-8', 0, n);
// A byte offset lands mid-line (and possibly mid-codepoint); drop the partial head.
if (start > 0) text = text.slice(text.indexOf('\n') + 1);
return { text, truncated: start > 0 };
} finally {
if (fd !== undefined) closeSync(fd);
}
function tailFile(osUser: AsUser, filePath: string, bytes: number): { text: string; truncated: boolean } {
const { text, truncated } = readTailAs(osUser, filePath, bytes);
// A byte offset lands mid-line (and possibly mid-codepoint); drop the partial head.
return { text: truncated ? text.slice(text.indexOf('\n') + 1) : text, truncated };
}
/**
@@ -723,7 +736,7 @@ export function loadBackgroundTask(who: ChatIdentity, taskId: string): Backgroun
}
if (target.endsWith('.jsonl')) {
const detail = parseClaudeTranscript(target, taskId);
const detail = parseClaudeTranscript(who.osUser, target, taskId);
if (!detail) return null;
const messages = detail.messages.map((m) =>
m.role === 'tool' && m.output && m.output.length > OUTPUT_CAP
@@ -734,7 +747,7 @@ export function loadBackgroundTask(who: ChatIdentity, taskId: string): Backgroun
}
try {
const { text, truncated } = tailFile(target, LOG_TAIL_BYTES);
const { text, truncated } = tailFile(who.osUser, target, LOG_TAIL_BYTES);
return { kind: 'log', text, truncated };
} catch {
return null;
@@ -746,17 +759,11 @@ export function loadBackgroundTask(who: ChatIdentity, taskId: string): Backgroun
// real `cwd` back from each group's transcripts so the UI can offer "jump to any project's sessions".
/** Read the `cwd` recorded in a transcript, from a bounded head read (cwd appears in early entries). */
function firstCwd(filePath: string): string {
let fd: number | undefined;
function firstCwd(osUser: AsUser, filePath: string): string {
try {
fd = openSync(filePath, 'r');
const buf = Buffer.alloc(32768);
const n = readSync(fd, buf, 0, buf.length, 0);
return buf.toString('utf-8', 0, n).match(/"cwd":"([^"]*)"/)?.[1] ?? '';
return readHeadAs(osUser, filePath, 32768).match(/"cwd":"([^"]*)"/)?.[1] ?? '';
} catch {
return '';
} finally {
if (fd !== undefined) closeSync(fd);
}
}
@@ -768,30 +775,30 @@ export function listClaudePwds(who: ChatIdentity): ClaudePwd[] {
const defaultCwd = who.home;
const byCwd = new Map<string, { count: number; updatedAt: string }>();
if (existsSync(projectsDir)) {
for (const group of readdirSync(projectsDir)) {
const groupDir = join(projectsDir, group);
let files: string[];
try {
files = readdirSync(groupDir).filter((f) => f.endsWith('.jsonl'));
} catch {
continue; // not a directory
}
if (files.length === 0) continue;
// One listing for the whole tree, grouped here. This used to be `readdirSync` + a `statSync` per file,
// and BOTH are EACCES for a member — the readdir threw uncaught, so this endpoint answered 500 rather
// than answering wrongly. That 500 was the visible half of the empty conversation list.
const byGroup = new Map<string, { count: number; newest: number; first: string }>();
for (const file of listTranscriptsAs(who.osUser, projectsDir)) {
const prev = byGroup.get(file.slug);
byGroup.set(file.slug, {
count: (prev?.count ?? 0) + 1,
newest: Math.max(prev?.newest ?? 0, file.mtimeMs),
first: prev?.first ?? file.id,
});
}
const cwd = firstCwd(join(groupDir, files[0]!));
if (!cwd) continue;
let updatedAt = '';
for (const f of files) {
const m = statSync(join(groupDir, f)).mtime.toISOString();
if (m > updatedAt) updatedAt = m;
}
const prev = byCwd.get(cwd);
byCwd.set(cwd, {
count: (prev?.count ?? 0) + files.length,
updatedAt: prev && prev.updatedAt > updatedAt ? prev.updatedAt : updatedAt,
});
}
for (const [slug, group] of byGroup) {
// The cwd is a property of the transcript's entries, not of the slug, which is lossy and cannot be
// reversed. Any file in the group answers it.
const cwd = firstCwd(who.osUser, join(projectsDir, slug, `${group.first}.jsonl`));
if (!cwd) continue;
const updatedAt = new Date(group.newest).toISOString();
const prev = byCwd.get(cwd);
byCwd.set(cwd, {
count: (prev?.count ?? 0) + group.count,
updatedAt: prev && prev.updatedAt > updatedAt ? prev.updatedAt : updatedAt,
});
}
if (!byCwd.has(defaultCwd)) byCwd.set(defaultCwd, { count: 0, updatedAt: '' });
@@ -809,13 +816,12 @@ export function listClaudePwds(who: ChatIdentity): ClaudePwd[] {
* mtime-cached, which is what makes calling this on every request cheap.
*/
function scanGroup(who: ChatIdentity, cwd: string): TranscriptSummary[] {
const dir = join(claudeProjectsDir(who.home), projectSlug(cwd));
if (!existsSync(dir)) return [];
const slug = projectSlug(cwd);
const dir = join(claudeProjectsDir(who.home), slug);
const sessions: TranscriptSummary[] = [];
for (const file of readdirSync(dir)) {
if (!file.endsWith('.jsonl')) continue;
const summary = summarizeTranscript(join(dir, file), file.replace(/\.jsonl$/, ''));
for (const file of listTranscriptsAs(who.osUser, claudeProjectsDir(who.home), slug)) {
const summary = summarizeTranscript(who.osUser, join(dir, `${file.id}.jsonl`), file.id, file.mtimeMs);
if (summary) sessions.push(summary);
}
return applyLineage(sessions);
@@ -864,40 +870,28 @@ export function claudeSessionContext(
* anywhere hotter.
*/
export function liveSessionTitle(who: ChatIdentity, sessionId: string): { title: string; cwd: string } | null {
const projectsDir = claudeProjectsDir(who.home);
let slugs: string[];
const filePath = locateTranscript(who, sessionId);
if (!filePath) return null;
// The cwd is a property of the transcript's entries, so the first one carrying it settles which group
// this session belongs to — no need to reverse the slug, which is lossy.
let cwd: string | null = null;
try {
slugs = readdirSync(projectsDir);
for (const line of readTextAs(who.osUser, filePath).split('\n')) {
if (!line.trim()) continue;
const entry = JSON.parse(line) as { cwd?: string };
if (entry.cwd) {
cwd = entry.cwd;
break;
}
}
} catch {
return null;
}
if (!cwd) return null;
for (const slug of slugs) {
const filePath = join(projectsDir, slug, `${sessionId}.jsonl`);
if (!existsSync(filePath)) continue;
// The cwd is a property of the transcript's entries, so the first one carrying it settles which
// group this session belongs to — no need to reverse the slug, which is lossy.
let cwd: string | null = null;
try {
for (const line of readFileSync(filePath, 'utf-8').split('\n')) {
if (!line.trim()) continue;
const entry = JSON.parse(line) as { cwd?: string };
if (entry.cwd) {
cwd = entry.cwd;
break;
}
}
} catch {
return null;
}
if (!cwd) return null;
const context = claudeSessionContext(who, cwd, sessionId);
return context ? { title: context.title, cwd } : null;
}
return null;
const context = claudeSessionContext(who, cwd, sessionId);
return context ? { title: context.title, cwd } : null;
}
/**
+24 -3
View File
@@ -351,9 +351,30 @@ async function handleChat(
logger.info('Model selected for chat', { sessionId, model, clientModel: msg.model || null });
// Route by harness: claude-code → Claude sidecar; anything else → OpenCode server.
return isClaudeModel(model)
? handleClaudeCodeChat(ws, sessionId, model, msg, prompt)
: handleOpenCodeChat(ws, sessionId, model, msg, prompt);
if (isClaudeModel(model)) return handleClaudeCodeChat(ws, sessionId, model, msg, prompt);
// ── OpenCode is owner-only until it carries an identity ──
//
// `handleOpenCodeChat` resolves its cwd against `getOwnerHomeDir(email)` — a function that discards the
// email it is given and always answers the owner — and the sidecar runs one shared `opencode serve` as the
// service user. So a turn here executes AS THE OWNER, IN THE OWNER'S HOME, whoever asked. It carried a
// comment describing itself as owner-only; this is the check that comment assumed existed.
//
// Reached by any account with the `chat` grant, which every role holds by default, and `isClaudeModel` is a
// `startsWith` — so a typo'd model string lands here too, not just a deliberate choice. `model` is
// client-supplied and never validated against the catalogue, so hiding these in `/chat/models` is not a
// substitute for refusing them here.
const identity = await resolveTurnIdentity(userId);
if (identity.kind !== 'owner') {
logger.warn('Refused an OpenCode turn for a non-owner', { userId, model, sessionId });
sendToClient(ws, {
type: 'error',
message: 'OpenCode is only available to the server owner. Pick a Claude model instead.',
});
return;
}
return handleOpenCodeChat(ws, sessionId, model, msg, prompt);
}
async function handleClaudeCodeChat(
+119
View File
@@ -0,0 +1,119 @@
import { createRouter } from '../../create-router';
import * as errors from '../../custom-errors';
import { isSuperAdmin } from '../../super-admin';
import { recordPluginInstall, removePluginInstall, setPluginEnabled } from 'officerdb';
import { mountPrefix } from '../../plugins/manifest';
import { refreshPluginMounts, snapshotPlugins } from '../../plugins/mount';
// /api/plugins — what is on this machine, what is installed, and the four verbs that change it.
//
// Owner only, in its own right. Installing a plugin mounts routes and (later) starts a process, which is
// an administrative act however many members share the server. The capability layer covers it too; this
// is the belt to that braces, the same shape `/api/app-store` uses.
//
// ── This is not the app store ──
//
// The app store installs SIDECARS from a compiled-in catalogue, provisioning containers and asking the
// user questions. This installs PLUGINS from the tree, and asks nothing: put the code there, push the
// schema, mount the routes. The two coexist until the app store is rebuilt on this.
export const pluginsRouter = createRouter();
pluginsRouter.use(async (ctx, next) => {
if (!(await isSuperAdmin(ctx.get('user')))) throw errors.FORBIDDEN('Plugins are owner-only');
return next();
});
/**
* GET /api/plugins — every plugin in the tree, with what the database knows about each.
*
* Reports `broken` alongside rather than failing: a directory with an unreadable manifest is something to
* show the owner, and refusing the whole list because one plugin is malformed would hide the nine that
* are fine.
*/
pluginsRouter.get('/', async (ctx) => {
const { states, broken } = await snapshotPlugins();
return ctx.json({
plugins: states.map(({ plugin, install, outdated }) => ({
appName: plugin.appName,
prefix: mountPrefix(plugin),
label: plugin.manifest.label,
summary: plugin.manifest.summary,
icon: plugin.manifest.icon,
color: plugin.manifest.color,
publisher: plugin.manifest.publisher,
version: plugin.manifest.version,
platform: plugin.manifest.platform,
permissions: plugin.manifest.permissions,
// What the tree declared. The UI shows these so "installed but does nothing" is legible.
has: {
api: !!plugin.api,
schema: !!plugin.schema,
sidecar: !!plugin.sidecar,
web: !!plugin.web,
},
installed: !!install,
enabled: install?.enabled ?? false,
installedVersion: install?.version ?? null,
outdated,
})),
broken,
});
});
/** The plugin by name, or a 404 naming it. Shared by every verb below. */
async function findPlugin(appName: string) {
const { states } = await snapshotPlugins();
const state = states.find((s) => s.plugin.appName === appName);
if (!state) throw errors.NOT_FOUND(`No plugin directory named "${appName}"`);
return state;
}
/**
* POST /api/plugins/:appName/install
*
* Idempotent, and re-installing is how a plugin is upgraded: the row follows the version on disk. It does
* not touch `enabled`, so re-installing something the owner had switched off does not switch it back on.
*
* `[open]` The schema push and the sidecar's PM2 entry are not wired yet — this records the install and
* mounts the routes. A plugin with `db/schema.ts` or `sidecar/` will need both before it works end to end.
*/
pluginsRouter.post('/:appName/install', async (ctx) => {
const { plugin } = await findPlugin(ctx.req.param('appName'));
await recordPluginInstall(plugin.appName, plugin.manifest.version);
const mounts = await refreshPluginMounts();
return ctx.json({ ok: true, appName: plugin.appName, ...mounts });
});
/**
* POST /api/plugins/:appName/uninstall
*
* Drops the row and unmounts. Deletes nothing the plugin owns — its tables and every row in them survive,
* so reinstalling is a restore rather than a fresh start. Dropping a plugin's data is a separate and
* deliberate act, not a side effect of an unrelated one.
*/
pluginsRouter.post('/:appName/uninstall', async (ctx) => {
const appName = ctx.req.param('appName');
const removed = await removePluginInstall(appName);
if (!removed) throw errors.NOT_FOUND(`"${appName}" is not installed`);
const mounts = await refreshPluginMounts();
return ctx.json({ ok: true, appName, ...mounts });
});
/** POST /api/plugins/:appName/enable — mount its routes again. Nothing else changes. */
pluginsRouter.post('/:appName/enable', async (ctx) => {
const appName = ctx.req.param('appName');
const row = await setPluginEnabled(appName, true);
if (!row) throw errors.NOT_FOUND(`"${appName}" is not installed`);
const mounts = await refreshPluginMounts();
return ctx.json({ ok: true, appName, enabled: true, ...mounts });
});
/** POST /api/plugins/:appName/disable — unmount, keep everything. The reversible middle ground. */
pluginsRouter.post('/:appName/disable', async (ctx) => {
const appName = ctx.req.param('appName');
const row = await setPluginEnabled(appName, false);
if (!row) throw errors.NOT_FOUND(`"${appName}" is not installed`);
const mounts = await refreshPluginMounts();
return ctx.json({ ok: true, appName, enabled: false, ...mounts });
});
-48
View File
@@ -1,48 +0,0 @@
import { createRouter } from '../../create-router';
import { getHeadscaleServerUrl } from '../headscale/router';
// Enrollment for OffTail, the in-app Tailscale. Authenticate the owner, forward to officer-headscale, and
// hold no Headscale knowledge whatsoever — no URL, no admin key, no user name.
//
// This file used to mint the pre-auth key itself, from HEADSCALE_URL / HEADSCALE_API_KEY / HEADSCALE_USER
// read out of the host env. Three globals describe exactly one server; Officer keeps a registry of many in
// `headscale_servers`, one active at a time, so the env could contradict the server the owner had selected.
// The two credential vars were later removed and the failure was silent — `if (!base || !apiKey)` returned
// 503 before the rest of the route ever ran, so enrollment had simply stopped working and said nothing.
// The logic now lives in the sidecar that owns the registry (src/servers/sidecar/headscale/enroll.ts).
//
// It stays mounted at /api/vpn rather than moving under /api/headscale because the path is a contract:
// enrollVpn() in the mobile core POSTs exactly /api/vpn/enroll. createSidecarProxy strips its own prefix
// and cannot express that rewrite, so this one forward is spelled out by hand.
export const vpnRouter = createRouter();
// POST /api/vpn/enroll → { controlUrl, authKey }
//
// The response shape is the other half of the contract: enrollVpn() in @officer/core destructures exactly
// those two fields, so changing them means changing the mobile app too.
vpnRouter.post('/enroll', async (ctx) => {
const baseUrl = getHeadscaleServerUrl();
if (!baseUrl) return ctx.json({ error: 'headscale sidecar not available' }, 503);
// Forwarded verbatim: an optional {userId} picks the owning Headscale user when the server has several.
const body = await ctx.req.arrayBuffer();
let upstream: Response;
try {
upstream = await fetch(`${baseUrl}/_officer/enroll`, {
method: 'POST',
headers: {
'content-type': ctx.req.header('content-type') ?? 'application/json',
// The authenticated owner. The sidecar binds loopback only, so its presence is the trust signal.
'X-Officer-User': String(ctx.get('user').id),
},
body: body.byteLength ? body : undefined,
});
} catch (err) {
console.error('[vpn] headscale sidecar unreachable:', err);
return ctx.json({ error: 'headscale sidecar unreachable' }, 502);
}
return new Response(upstream.body, { status: upstream.status, headers: new Headers(upstream.headers) });
});
+1 -8
View File
@@ -26,14 +26,7 @@ import { CAPABILITIES } from '../capabilities/registry';
// The processes a core install runs, mirroring CORE_PROCESSES in
// scripts/setup/officer-setup/lib/services.sh. Duplicated deliberately: the generator is shell and this
// is a test, and the alternative is the test reading a file the repository does not contain.
const CORE = [
'officer',
'officer-anthropic-proxy',
'officer-claude-code',
'officer-opencode',
'officer-pty',
'officer-headscale',
];
const CORE = ['officer', 'officer-anthropic-proxy', 'officer-claude-code', 'officer-opencode', 'officer-pty'];
describe('the catalogue against the real estate', () => {
it('does not offer to install the baseline', () => {
+25 -10
View File
@@ -236,16 +236,17 @@ export const CAPABILITIES: Capability[] = [
api: [],
routes: ['/invoices'],
},
{
key: 'vpn',
label: 'VPN',
description: 'Enrol your own devices on the tailnet',
kind: 'app',
api: ['/vpn'],
// Minting a pre-auth key for your own device is the entire point of the capability, and the key is
// bound to the caller. Administering the tailnet is `headscale`, which is admin-only.
personal: ['/'],
},
// `vpn` (POST /api/vpn/enroll) was here until 2026-08-14 — the one member-grantable piece of headscale,
// minting a pre-auth key bound to the caller. Deleted because it had no caller anywhere: the standalone
// OffScale app gates it on `embedded`, which it never sets, and it never will — the app is permanently
// independent of the platform, since the thing that gets you to the platform cannot itself need it.
//
// Device enrolment did not go away, it moved out. A phone claims an invite from the Companion at
// `${invite.base}/api/v1/enroll/claim`, which is a different component entirely and does not involve
// Officer. Confirmed against the mobile monorepo and the companion repo before removal.
//
// What this does cost: `headscale` is `admin`, so with `vpn` gone no member-grantable headscale surface
// remains. Reintroduce one here if members ever need to enrol their own devices through Officer.
// Core, not app — and this was a real defect, not a preference. `/api/dashboards` is not a feature, it is
// the per-user key-value store where EVERY workspace screen keeps its layout (`screens/files`,
// `ws-layout-*`, panel config). `WorkspaceView` renders nothing until that store has loaded, so gating it
@@ -367,6 +368,20 @@ export const CAPABILITIES: Capability[] = [
api: ['/app-store'],
routes: ['/app-store'],
},
{
key: 'plugins',
label: 'Plugins',
description: 'Install, enable and remove the plugins this server runs',
// Admin for the same reason as the app store above: installing a plugin mounts routes and starts a
// process, which is process control rather than a feature to grant a read of.
//
// Note this capability guards the MANAGEMENT surface, not the plugins themselves. A plugin declares
// its own permissions in its manifest, and those are what gate its routes — so a member can hold
// `offscale` at read without being able to install or remove anything.
kind: 'admin',
api: ['/plugins'],
routes: ['/plugins'],
},
{
key: 'server-admin',
label: 'Server settings',
+10
View File
@@ -22,6 +22,16 @@ import { homedir } from 'node:os';
// is the check that says so out loud instead of silently writing to the wrong place.
export const OFFICER_ROOT = resolve(process.cwd(), '..');
/**
* The repo itself — the working directory, named rather than re-derived at each call site.
*
* Plugins live under this rather than beside it (`platform/plugins/<app-name>/`), which is the whole
* developer story: bun links the workspace packages into the root `node_modules`, so anything inside the
* repo can `import { useClient } from 'hooks/useClient'` with no publishing and no version negotiation.
* A plugin one directory higher would resolve none of it.
*/
export const PLATFORM_DIR = process.cwd();
export const DATA_PATH = join(OFFICER_ROOT, 'data');
// Unified, file-based store for all agent items, living outside the repo. Every skill/tool/task/
+162 -106
View File
@@ -19,6 +19,7 @@ import { uploadRouter } from './api/upload/upload';
import { settingsRouter } from './api/settings/settings';
import { dashboardsRouter } from './api/dashboards';
import { router as fileBrowserRouter } from './api/file-browser/router';
import { pluginsRouter } from './api/plugins/router';
// import { musicRouter } from './api/music/router';
// import { vaultRouter } from './api/vault/router';
// import { publicVaultRouter, VAULT_ONLY_PREFIXES, isBitwardenClient } from './api/vault/public-router';
@@ -30,7 +31,6 @@ import { headscaleRouter } from './api/headscale/router';
// import { jellyfinRouter } from './api/jellyfin/router';
// import { photosRouter } from './api/photos/router';
// import { walletRouter } from './api/wallet/router';
import { vpnRouter } from './api/vpn/router';
import { terminalRouter } from './api/terminal/sidecar-server';
// import { caldavRouter } from './api/dav/sidecar-server';
// import { memosRouter } from './api/memos/router';
@@ -64,7 +64,29 @@ export { Hono };
export { createRouter };
export type { HonoVariables };
export const honoServer = new Hono<{ Variables: HonoVariables }>();
/**
* A plugin's router and where it mounts — a plain pair, so this file needs no plugin knowledge at all.
* Built by `plugins/mount.ts`, which is the side that knows what a manifest is.
*/
export type MountedPlugin = { prefix: string; router: ReturnType<typeof createRouter> };
// ── The app is BUILT, not assembled once ──
//
// It used to be a module-level `new Hono()` with forty statements run at import. That cannot express
// installing a plugin: Hono's default SmartRouter throws `Can not add a route since the matcher is
// already built` the moment a route is added after serving begins, and Hono has no API to REMOVE a route
// at all — so uninstall was impossible even with a router that allowed adding.
//
// So nothing is added to a live app. A fresh one is built from the current plugin set and swapped in:
//
// honoServer = buildHonoApp(plugins) // install, uninstall, enable, disable — all the same call
//
// `server.tsx` serves it through a CLOSURE (`(req, server) => honoServer.fetch(req, server)`), not the
// bound `honoServer.fetch`, so the reassignment above IS the swap. Verified end to end: a route 404s
// before install, 200s after, and 404s again after uninstall, with core routes untouched throughout.
//
// Two things this buys over adding routes to a live app: the default SmartRouter is kept, so the fast
// RegExpRouter path survives — and uninstall is expressible, which an add-only API cannot do.
// Origin checking was removed on 2026-08-13, so CORS echoes back whatever Origin it is given. That is
// not a loosening: the check it replaced defaulted to off, so this is what every real install already
@@ -88,93 +110,6 @@ const corsMiddleware = cors({
const isDavPath = (path: string) =>
path === '/dav' || path.startsWith('/dav/') || path === '/.well-known/caldav' || path === '/.well-known/carddav';
honoServer.use((ctx, next) => (isDavPath(ctx.req.path) ? next() : corsMiddleware(ctx, next)));
// The authorization gate: a valid non-owner token reaches only what its role grants. Ahead of every
// router, and it re-verifies the token itself so it covers routes that never mount userMiddleware.
honoServer.use(capabilityGateMiddleware);
honoServer.get('/api', (ctx) => ctx.json({ officerAPI: 'ok' }));
honoServer.route('/api/auth', authRouter);
honoServer.route('/api/landing-page-data', landingPageDataRouter);
honoServer.route('/api/waitlist', waitlistRouter);
// Vaultwarden reverse-proxy — mounted TOP-LEVEL (not under protectedRouter): the Bitwarden client
// carries its own bearer token, not a platform session JWT, so userMiddleware would 401 it. The
// notifications WebSocket is upgraded at the serve level (server.tsx).
// honoServer.route('/api/vault', vaultRouter); // switched off 2026-08-13 — Vaultwarden is a plugin
// The same Vaultwarden, with NO Officer authentication, so the Bitwarden browser extension can point at
// this host instead of at a second public hostname for Vaultwarden. Deliberately its own mount rather
// than a mode of the router above: that one requires an Officer session and swaps the caller's
// Authorization header for a server-held token, and blending the two would put an unauthenticated branch
// inside the authenticated path. Temporary — see public-router.ts for what replaces it and why leaving it
// open is not a new exposure.
// honoServer.route('/vaultwarden', publicVaultRouter); // switched off with the above
// …and at the ROOT, so the extension can be pointed at the bare Officer URL with no path at all.
//
// Registered BEFORE `/api` is mounted, because hono matches in registration order and this has to win
// for a Bitwarden client. It is deliberately narrow: the four prefixes below belong to Vaultwarden and
// to nothing else here, and `/api/*` is diverted ONLY when the request carries a Bitwarden client
// header. An ordinary Officer request never matches, so nothing that worked before changes.
// for (const prefix of VAULT_ONLY_PREFIXES) honoServer.route(prefix, publicVaultRouter);
//
// honoServer.use('/api/*', async (ctx, next) => {
// if (!isBitwardenClient(ctx.req.raw.headers)) return next();
// return publicVaultRouter.fetch(ctx.req.raw, ctx.env);
// });
honoServer.get('/api/integrations/google/callback', googleCallbackHandler);
// Agent-to-agent handoff — mounted TOP-LEVEL for the same reason the vault is: the caller is a Claude
// session running a curl, and it carries a per-panel bearer token rather than a platform session JWT,
// so userMiddleware would 401 it and a capability lookup would have no account to resolve. The token
// identifies exactly one agent panel and authorises exactly one action: deliver a prompt to a named
// peer on that panel's own dashboard. See servers/api/agent-handoff/router.ts.
honoServer.route('/api/agent-handoff', agentHandoffRouter);
// CalDAV/CardDAV for phones and desktop clients — mounted TOP-LEVEL for the same reason the vault is:
// DAVx5, iOS and Thunderbird authenticate with HTTP Basic on every request and have nowhere to put a
// platform JWT, so userMiddleware would 401 them. The credential is a scoped DAV app password; see
// api/dav/sync-router.ts.
// The iOS profile download, registered BEFORE the /dav mount below because hono matches in registration
// order and davSyncRouter's `/*` would otherwise demand HTTP Basic for it. Safari has no credential to
// offer — it was handed a URL by the app and nothing else — so the one-shot token in the path IS the
// authentication. Minted by POST /api/dav/provision/ios; see api/dav/ios-profile.ts.
honoServer.get('/dav/provision/:file', (ctx) => {
const file = ctx.req.param('file');
const token = file.endsWith('.mobileconfig') ? file.slice(0, -'.mobileconfig'.length) : null;
const body = token ? claimIosProfile(token) : null;
// Expired, already used, or never existed — all the same 404. There is nothing useful to tell a
// caller who has the wrong token, and distinguishing the cases would confirm that a token once existed.
if (!body) return ctx.text('not found', 404);
return new Response(body as unknown as BodyInit, {
headers: {
// Mandatory. iOS identifies a configuration profile by MIME type; served as octet-stream or
// text/xml the file downloads and the OS does nothing with it.
'Content-Type': 'application/x-apple-aspen-config',
'Cache-Control': 'no-store',
},
});
});
// honoServer.route('/dav', davSyncRouter); // plugin — switched off 2026-08-13
// Autodiscovery. This is most of what makes adding an account on a phone feel transparent instead of
// fiddly: the client is given a bare domain and probes these paths UNAUTHENTICATED before it has any
// credential, so they must sit above every auth gate. Without them iOS in particular degrades to
// demanding a full collection URL, which is exactly the sort of thing that makes self-hosting feel
// worse than the commercial product it is replacing.
// `.all`, not `.get`: RFC 6764 §6 has the client probe the well-known URI with the method it actually
// wants to use, and iOS sends PROPFIND, not GET. Registered as GET-only these answered 404 to every real
// client while looking perfectly healthy in a browser.
// honoServer.all('/.well-known/caldav', (ctx) => ctx.redirect('/dav/', 301));
// honoServer.all('/.well-known/carddav', (ctx) => ctx.redirect('/dav/', 301));
const protectedRouter = createRouter();
protectedRouter.use(bodyParser());
protectedRouter.use(userMiddleware);
// The mount table, as DATA rather than forty statements.
//
// The reason is the capability registry: assertCapabilityTotality refuses to boot unless every mounted
@@ -205,6 +140,7 @@ const PROTECTED_MOUNTS: [prefix: string, router: ReturnType<typeof createRouter>
// ['/memos', memosRouter], // plugin — switched off 2026-08-13
// ['/gitea', giteaRouter], // plugin — switched off 2026-08-13
['/app-store', appStoreRouter],
['/plugins', pluginsRouter],
// ['/caldav', caldavRouter], // the JSON door for Officer's own calendar/contacts UI — plugin, switched off 2026-08-13
// ['/dav', davRouter], // app-password management (the sync door is /dav, top-level) — plugin, switched off
// ['/notify', notifyRouter], // plugin — switched off 2026-08-13
@@ -214,7 +150,6 @@ const PROTECTED_MOUNTS: [prefix: string, router: ReturnType<typeof createRouter>
// ['/jellyfin', jellyfinRouter], // plugin — switched off 2026-08-13
// ['/photos', photosRouter], // plugin — switched off 2026-08-13
// ['/wallet', walletRouter], // plugin — switched off 2026-08-13
['/vpn', vpnRouter],
['/system-monitor', systemMonitorRouter],
['/activity', activityRouter],
['/dock', dockRouter],
@@ -230,8 +165,6 @@ const PROTECTED_MOUNTS: [prefix: string, router: ReturnType<typeof createRouter>
// ['/desktop', desktopRouter], // plugin — switched off 2026-08-13
];
for (const [prefix, router] of PROTECTED_MOUNTS) protectedRouter.route(prefix, router);
/** Every prefix served behind the account gate. Read by the capability totality check at boot. */
export const PROTECTED_API_PREFIXES: string[] = PROTECTED_MOUNTS.map(([prefix]) => prefix);
@@ -248,21 +181,144 @@ export const UNPROTECTED_API_PREFIXES: string[] = [
'/agent-handoff',
];
honoServer.route('/api', protectedRouter);
/**
* Build the whole application from the plugins currently installed.
*
* Pure: it reads nothing and mutates nothing. Everything it needs arrives as an argument, so a caller
* can build an app for a hypothetical plugin set — which is what makes the swap testable without a
* database, a filesystem or a running server.
*/
export function buildHonoApp(plugins: MountedPlugin[] = []): Hono<{ Variables: HonoVariables }> {
const app = new Hono<{ Variables: HonoVariables }>();
honoServer.onError((error, ctx) => {
if (error instanceof CustomError) {
if (error.returnValue) {
if (typeof error.returnValue === 'string') {
return ctx.text(error.returnValue, error.statusCode);
} else {
return ctx.json(error.returnValue, error.statusCode);
app.use((ctx, next) => (isDavPath(ctx.req.path) ? next() : corsMiddleware(ctx, next)));
// The authorization gate: a valid non-owner token reaches only what its role grants. Ahead of every
// router, and it re-verifies the token itself so it covers routes that never mount userMiddleware.
app.use(capabilityGateMiddleware);
app.get('/api', (ctx) => ctx.json({ officerAPI: 'ok' }));
app.route('/api/auth', authRouter);
app.route('/api/landing-page-data', landingPageDataRouter);
app.route('/api/waitlist', waitlistRouter);
// Vaultwarden reverse-proxy — mounted TOP-LEVEL (not under protectedRouter): the Bitwarden client
// carries its own bearer token, not a platform session JWT, so userMiddleware would 401 it. The
// notifications WebSocket is upgraded at the serve level (server.tsx).
// app.route('/api/vault', vaultRouter); // switched off 2026-08-13 — Vaultwarden is a plugin
// The same Vaultwarden, with NO Officer authentication, so the Bitwarden browser extension can point at
// this host instead of at a second public hostname for Vaultwarden. Deliberately its own mount rather
// than a mode of the router above: that one requires an Officer session and swaps the caller's
// Authorization header for a server-held token, and blending the two would put an unauthenticated branch
// inside the authenticated path. Temporary — see public-router.ts for what replaces it and why leaving it
// open is not a new exposure.
// app.route('/vaultwarden', publicVaultRouter); // switched off with the above
// …and at the ROOT, so the extension can be pointed at the bare Officer URL with no path at all.
//
// Registered BEFORE `/api` is mounted, because hono matches in registration order and this has to win
// for a Bitwarden client. It is deliberately narrow: the four prefixes below belong to Vaultwarden and
// to nothing else here, and `/api/*` is diverted ONLY when the request carries a Bitwarden client
// header. An ordinary Officer request never matches, so nothing that worked before changes.
// for (const prefix of VAULT_ONLY_PREFIXES) app.route(prefix, publicVaultRouter);
//
// app.use('/api/*', async (ctx, next) => {
// if (!isBitwardenClient(ctx.req.raw.headers)) return next();
// return publicVaultRouter.fetch(ctx.req.raw, ctx.env);
// });
app.get('/api/integrations/google/callback', googleCallbackHandler);
// Agent-to-agent handoff — mounted TOP-LEVEL for the same reason the vault is: the caller is a Claude
// session running a curl, and it carries a per-panel bearer token rather than a platform session JWT,
// so userMiddleware would 401 it and a capability lookup would have no account to resolve. The token
// identifies exactly one agent panel and authorises exactly one action: deliver a prompt to a named
// peer on that panel's own dashboard. See servers/api/agent-handoff/router.ts.
app.route('/api/agent-handoff', agentHandoffRouter);
// CalDAV/CardDAV for phones and desktop clients — mounted TOP-LEVEL for the same reason the vault is:
// DAVx5, iOS and Thunderbird authenticate with HTTP Basic on every request and have nowhere to put a
// platform JWT, so userMiddleware would 401 them. The credential is a scoped DAV app password; see
// api/dav/sync-router.ts.
// The iOS profile download, registered BEFORE the /dav mount below because hono matches in registration
// order and davSyncRouter's `/*` would otherwise demand HTTP Basic for it. Safari has no credential to
// offer — it was handed a URL by the app and nothing else — so the one-shot token in the path IS the
// authentication. Minted by POST /api/dav/provision/ios; see api/dav/ios-profile.ts.
app.get('/dav/provision/:file', (ctx) => {
const file = ctx.req.param('file');
const token = file.endsWith('.mobileconfig') ? file.slice(0, -'.mobileconfig'.length) : null;
const body = token ? claimIosProfile(token) : null;
// Expired, already used, or never existed — all the same 404. There is nothing useful to tell a
// caller who has the wrong token, and distinguishing the cases would confirm that a token once existed.
if (!body) return ctx.text('not found', 404);
return new Response(body as unknown as BodyInit, {
headers: {
// Mandatory. iOS identifies a configuration profile by MIME type; served as octet-stream or
// text/xml the file downloads and the OS does nothing with it.
'Content-Type': 'application/x-apple-aspen-config',
'Cache-Control': 'no-store',
},
});
});
// app.route('/dav', davSyncRouter); // plugin — switched off 2026-08-13
// Autodiscovery. This is most of what makes adding an account on a phone feel transparent instead of
// fiddly: the client is given a bare domain and probes these paths UNAUTHENTICATED before it has any
// credential, so they must sit above every auth gate. Without them iOS in particular degrades to
// demanding a full collection URL, which is exactly the sort of thing that makes self-hosting feel
// worse than the commercial product it is replacing.
// `.all`, not `.get`: RFC 6764 §6 has the client probe the well-known URI with the method it actually
// wants to use, and iOS sends PROPFIND, not GET. Registered as GET-only these answered 404 to every real
// client while looking perfectly healthy in a browser.
// app.all('/.well-known/caldav', (ctx) => ctx.redirect('/dav/', 301));
// app.all('/.well-known/carddav', (ctx) => ctx.redirect('/dav/', 301));
const protectedRouter = createRouter();
protectedRouter.use(bodyParser());
protectedRouter.use(userMiddleware);
for (const [prefix, router] of PROTECTED_MOUNTS) protectedRouter.route(prefix, router);
// Plugin routes, mounted behind the same account gate as everything else — a plugin is part of the
// application, not a guest in it, so it gets no separate door and no weaker middleware.
//
// `mountPrefix` decides where, from the manifest's `publisher` and nothing else. Nothing here may
// branch on provenance: the moment first-party and third-party differ anywhere but that one function,
// they become two systems and only one of them is exercised.
for (const plugin of plugins) protectedRouter.route(plugin.prefix, plugin.router);
app.route('/api', protectedRouter);
app.onError((error, ctx) => {
if (error instanceof CustomError) {
if (error.returnValue) {
if (typeof error.returnValue === 'string') {
return ctx.text(error.returnValue, error.statusCode);
} else {
return ctx.json(error.returnValue, error.statusCode);
}
}
return ctx.text(error.message, error.statusCode);
}
return ctx.text(error.message, error.statusCode);
}
console.error('Unexpected error:', error.message);
console.log(error.stack);
return ctx.text('Internal Server Error', 500);
});
console.error('Unexpected error:', error.message);
console.log(error.stack);
return ctx.text('Internal Server Error', 500);
});
return app;
}
/**
* The live app. `let`, and reassigned by `rebuildHonoApp` — see the note at the top of this file.
*
* Starts with no plugins because discovery reads the disk and the database, which is asynchronous and
* must not happen at import. `server.tsx` rebuilds once both have answered.
*/
export let honoServer = buildHonoApp();
/** Swap the live app for one built from `plugins`. The whole of install, uninstall, enable and disable. */
export function rebuildHonoApp(plugins: MountedPlugin[]): Hono<{ Variables: HonoVariables }> {
honoServer = buildHonoApp(plugins);
return honoServer;
}
+37 -2
View File
@@ -297,11 +297,46 @@ export async function confineUserTree(params: {
try {
if (!existsSync(home)) await mkdir(home, { recursive: true });
// Traversable, not listable. Applied to DATA_PATH itself too: without it a member can read the
// directory and learn every other member's email address.
// Traversable, not listable — for everyone EXCEPT the members themselves, who are named below.
//
// ── Why "not listable" could not be kept ──
//
// 711 says: pass through, do not read. That is enough to `cd` into a home and not enough for a program
// that READS its ancestors, and at least one in daily use does. `bun run` primes its module-resolution
// cache by walking DOWN from `/` and opening every component of the cwd with `O_RDONLY|O_DIRECTORY`:
//
// openat("/home/pastilhas/officerdev/") = 6
// openat("/home/pastilhas/officerdev/data/") = -1 EACCES
// openat("/home/pastilhas/officerdev/data/<email>/") = -1 EACCES
//
// and dies with `CouldntReadCurrentDirectory` before it ever looks for `package.json`. `getcwd` succeeds;
// it is the read of the ancestors that fails. Traversal alone would do — `O_PATH` needs only `x` — so
// this is arguably Bun's bug, but it is not one this repository can fix, and it presents as a project
// being mysteriously unbuildable from a member's shell.
//
// The cost is stated plainly: a member can now `ls` DATA_PATH and learn the other accounts' email
// addresses. Their CONTENTS stay shut — every `home` is 700 and owned by its member, and every sibling
// is 700 and owned by the service user. What is given up is the account list, not any account's data.
//
// Named ACL entries rather than `chmod 755`, so this reaches members and not every account on the box.
await chmod(DATA_PATH, 0o711);
await chmod(accountDir, 0o711);
// After the chmods, never before — chmod recomputes the ACL mask from the group bits, which for 711 is
// `--x`, and that would clamp every member entry (including ones added by earlier provisions) down to
// traverse-only. Setting `m::rx` explicitly restores them all, so provisioning a second member does not
// silently re-break the first.
const uidEntry = `u:${params.uid}:rx`;
const openUp = await run(['sudo', '-n', 'setfacl', '-m', `${uidEntry},m::rx`, DATA_PATH, accountDir]);
if (!openUp.ok) {
return {
ok: false,
error:
`could not grant ${params.email} read access to ${DATA_PATH}: ${openUp.out}. ` +
`Without it their own tooling cannot resolve paths inside their home.`,
};
}
// Every sibling of `home` is the platform's. 700 means traversal alone does not open them.
const entries = await readdir(accountDir, { withFileTypes: true });
for (const entry of entries) {
+87
View File
@@ -0,0 +1,87 @@
import { afterAll, describe, expect, it } from 'bun:test';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { discoverPlugins } from './discover';
// Discovery against a real tree, because every assertion here is about the FILESYSTEM being the
// declaration. Mocking `existsSync` would test the mock.
const root = mkdtempSync(join(tmpdir(), 'officer-plugins-'));
afterAll(() => rmSync(root, { recursive: true, force: true }));
const MANIFEST = (over = '') => `export const manifest = {
publisher: 'officerdev', version: '1.0.0', platform: '>=1.0.0',
label: 'X', summary: 'x', icon: 'Network', color: '#fff',
permissions: [], ${over}
};`;
function plant(appName: string, files: Record<string, string>) {
const dir = join(root, appName);
for (const [rel, body] of Object.entries(files)) {
const full = join(dir, rel);
mkdirSync(join(full, '..'), { recursive: true });
writeFileSync(full, body);
}
return dir;
}
plant('full', {
'manifest.ts': MANIFEST(),
'api/router.ts': 'export const router = {};',
'db/schema.ts': 'export const t = {};',
'sidecar/index.ts': 'export {};',
'web/Router.tsx': 'export default () => null;',
'web/panels.ts': 'export const appRegistryMetas = [];',
});
plant('bare', { 'manifest.ts': MANIFEST() });
plant('nodeish', { 'manifest.ts': MANIFEST(), 'sidecar/index.mjs': 'export {};' });
plant('broken', { 'manifest.ts': 'export const manifest = { publisher: 1 };' });
plant('nomanifest', { 'api/router.ts': 'export const router = {};' });
plant('_scratch', { 'manifest.ts': MANIFEST() });
describe('discoverPlugins', () => {
it('reads what the tree declares, and nothing more', async () => {
const { plugins } = await discoverPlugins(root);
const full = plugins.find((p) => p.appName === 'full')!;
expect(full.api).toContain('api/router.ts');
expect(full.schema).toContain('db/schema.ts');
expect(full.sidecar).toEqual({ script: join(root, 'full/sidecar/index.ts'), runtime: 'bun' });
expect(full.web?.panels).toContain('web/panels.ts');
});
it('a manifest alone is a valid plugin — every other part is optional', async () => {
const { plugins } = await discoverPlugins(root);
const bare = plugins.find((p) => p.appName === 'bare')!;
expect([bare.api, bare.schema, bare.sidecar, bare.web]).toEqual([null, null, null, null]);
});
// The runtime is the extension, not a field, so it cannot contradict the file it describes.
it('reads the runtime off the extension', async () => {
const { plugins } = await discoverPlugins(root);
expect(plugins.find((p) => p.appName === 'nodeish')!.sidecar?.runtime).toBe('node');
});
it('takes the app name from the directory, so it cannot disagree with where the code sits', async () => {
const { plugins } = await discoverPlugins(root);
expect(plugins.map((p) => p.appName)).toContain('full');
});
// The property that matters most: one bad plugin must not take the platform down, or hide the good
// ones beside it. "Broken, and here is why" is renderable; a failed boot is only greppable.
it('collects broken plugins instead of throwing', async () => {
const { plugins, broken } = await discoverPlugins(root);
expect(broken.map((b) => b.appName).sort()).toEqual(['broken', 'nomanifest']);
expect(broken.find((b) => b.appName === 'nomanifest')!.error).toContain('no manifest.ts');
expect(plugins.length).toBeGreaterThan(0);
});
it('skips underscore and dot directories, which are scratch space', async () => {
const { plugins, broken } = await discoverPlugins(root);
expect([...plugins, ...broken].map((p) => p.appName)).not.toContain('_scratch');
});
it('is empty, not an error, when there is no plugins directory at all', async () => {
expect(await discoverPlugins(join(root, 'does-not-exist'))).toEqual({ plugins: [], broken: [] });
});
});
+125
View File
@@ -0,0 +1,125 @@
import { existsSync, readdirSync, statSync } from 'node:fs';
import { join } from 'node:path';
import { PLATFORM_DIR } from '../data-path';
import { manifestProblems, type DiscoveredPlugin, type PluginManifest } from './manifest';
// Finding plugins on disk.
//
// They live at `<platform>/plugins/<app-name>/` — INSIDE the repository, not beside it, and that is what
// makes the whole developer story work. Bun links the workspace packages into the root `node_modules`, so
// anything under the repo can `import { useClient } from 'hooks/useClient'` with no publishing, no package
// registry and no version negotiation. A plugin author clones the platform, drops their plugin in, and
// runs it in dev — the WordPress model — and the same tree is what the server builds from.
//
// Verified: `Bun.resolveSync('hooks/useClient', '<platform>/plugins/anything')` resolves.
//
// Discovery is by CONVENTION. Presence is the declaration:
//
// manifest.ts required — everything a directory listing cannot say
// api/router.ts a backend router
// db/schema.ts tables
// sidecar/index.ts a process (`.mjs` instead means node — see below)
// web/Router.tsx a frontend
// web/panels.ts panel apps
//
// Nothing here reads the database. This answers "what is on disk", which is a different question from
// "what is installed" — the install table answers that, and the two disagreeing is a state the app store
// has to render rather than a bug to prevent.
/** Where plugins live. Inside the repo, so the workspace packages resolve. */
export const PLUGINS_DIR = join(PLATFORM_DIR, 'plugins');
/**
* The runtime is the file extension, not a manifest field.
*
* `sidecar/index.mjs` runs under node, `sidecar/index.ts` under bun. Implicit, but it is the rule this
* repository 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.
*/
function findSidecar(dir: string): DiscoveredPlugin['sidecar'] {
const ts = join(dir, 'sidecar', 'index.ts');
if (existsSync(ts)) return { script: ts, runtime: 'bun' };
const mjs = join(dir, 'sidecar', 'index.mjs');
if (existsSync(mjs)) return { script: mjs, runtime: 'node' };
return null;
}
function findWeb(dir: string): DiscoveredPlugin['web'] {
const router = join(dir, 'web', 'Router.tsx');
if (!existsSync(router)) return null;
const panels = join(dir, 'web', 'panels.ts');
return { router, panels: existsSync(panels) ? panels : null };
}
const fileOrNull = (path: string): string | null => (existsSync(path) ? path : null);
/**
* Read one plugin directory.
*
* Throws with every problem at once rather than the first, because a manifest fixed one field per attempt
* is a manifest nobody finishes.
*/
export async function loadPlugin(dir: string, appName: string): Promise<DiscoveredPlugin> {
const manifestPath = join(dir, 'manifest.ts');
if (!existsSync(manifestPath)) throw new Error(`${appName}: no manifest.ts`);
let manifest: PluginManifest | null = null;
try {
const module = (await import(manifestPath)) as { manifest?: PluginManifest };
manifest = module.manifest ?? null;
} catch (err) {
throw new Error(`${appName}: manifest.ts failed to load — ${err instanceof Error ? err.message : String(err)}`);
}
const problems = manifestProblems(appName, manifest);
if (problems.length) throw new Error(`${appName}: ${problems.join('; ')}`);
return {
appName,
dir,
manifest: manifest as PluginManifest,
api: fileOrNull(join(dir, 'api', 'router.ts')),
schema: fileOrNull(join(dir, 'db', 'schema.ts')),
sidecar: findSidecar(dir),
web: findWeb(dir),
};
}
export type DiscoveryResult = {
plugins: DiscoveredPlugin[];
/** Directories that look like plugins but could not be read. Reported, never thrown — see below. */
broken: { appName: string; error: string }[];
};
/**
* Every plugin directory under `PLUGINS_DIR`.
*
* A broken plugin is COLLECTED, not thrown. One unreadable manifest must not stop the platform from
* booting or hide the nine plugins beside it that are fine — and "this one is broken, here is why" is
* something the app store can render, where a failed boot is something only a log can.
*/
export async function discoverPlugins(root: string = PLUGINS_DIR): Promise<DiscoveryResult> {
if (!existsSync(root)) return { plugins: [], broken: [] };
const plugins: DiscoveredPlugin[] = [];
const broken: { appName: string; error: string }[] = [];
for (const entry of readdirSync(root)) {
// `_`-prefixed directories are scratch space, and dotfiles are not plugins.
if (entry.startsWith('.') || entry.startsWith('_')) continue;
const dir = join(root, entry);
try {
if (!statSync(dir).isDirectory()) continue;
} catch {
continue;
}
try {
plugins.push(await loadPlugin(dir, entry));
} catch (err) {
broken.push({ appName: entry, error: err instanceof Error ? err.message : String(err) });
}
}
return { plugins: plugins.sort((a, b) => a.appName.localeCompare(b.appName)), broken };
}
+77
View File
@@ -0,0 +1,77 @@
import { describe, expect, it } from 'bun:test';
import { manifestProblems, mountPrefix, type PluginManifest } from './manifest';
const valid = (over: Partial<PluginManifest> = {}): PluginManifest => ({
publisher: 'officerdev',
version: '1.0.0',
platform: '>=1.0.0 <2.0.0',
label: 'Offscale',
summary: 'Your tailnet',
icon: 'Network',
color: '#818cf8',
permissions: [{ key: 'offscale', label: 'Offscale', description: 'The tailnet', ownerOnly: true }],
...over,
});
describe('mountPrefix', () => {
it('puts first-party plugins at the root', () => {
expect(mountPrefix({ appName: 'offscale', manifest: { publisher: 'officerdev' } })).toBe('/offscale');
});
it('puts everyone else under /p/<publisher>/', () => {
expect(mountPrefix({ appName: 'notes', manifest: { publisher: 'alice' } })).toBe('/p/alice/notes');
});
// The property the segment exists for: a third party cannot reach a core route's namespace, whatever
// they call their plugin. Without it, publishing `notes` at /api/notes would mean the platform could
// never add /api/notes itself.
it('cannot shadow a core route, whatever the plugin is called', () => {
for (const core of ['chat', 'users', 'terminal', 'auth', 'files']) {
expect(mountPrefix({ appName: core, manifest: { publisher: 'alice' } })).toBe(`/p/alice/${core}`);
}
});
});
describe('manifestProblems', () => {
it('accepts a good manifest', () => {
expect(manifestProblems('offscale', valid())).toEqual([]);
});
it('rejects a missing manifest with a reason rather than a crash', () => {
expect(manifestProblems('offscale', null)).toHaveLength(1);
});
it('reports every problem at once, not the first', () => {
// An install fixed one field per attempt is an install nobody finishes.
const problems = manifestProblems('offscale', { publisher: 'officerdev' } as Partial<PluginManifest>);
expect(problems.length).toBeGreaterThan(3);
});
// The app name is a URL segment, a SQL identifier prefix and a directory name simultaneously. Anything
// that is not safe in all three has to be refused at the door.
it.each([
['Offscale', 'uppercase'],
['1offscale', 'leading digit'],
['off scale', 'space'],
['off_scale', 'underscore'],
['off/scale', 'slash'],
['../escape', 'traversal'],
['', 'empty'],
])('refuses the directory name %p (%s)', (appName) => {
expect(manifestProblems(appName, valid()).length).toBeGreaterThan(0);
});
it('refuses a publisher that is not a safe path segment', () => {
expect(manifestProblems('notes', valid({ publisher: '../evil' })).length).toBeGreaterThan(0);
});
it('requires permissions to be an array, so [] is how a plugin says it gates nothing', () => {
expect(manifestProblems('notes', valid({ permissions: [] }))).toEqual([]);
expect(manifestProblems('notes', valid({ permissions: undefined })).length).toBeGreaterThan(0);
});
it('names the permission that is malformed, by index', () => {
const problems = manifestProblems('notes', valid({ permissions: [{ label: 'x' }] as never }));
expect(problems.some((p) => p.includes('permissions[0].key'))).toBe(true);
});
});
+151
View File
@@ -0,0 +1,151 @@
// What a plugin declares about itself, and what the tree declares for it.
//
// ── The manifest holds only what a directory listing cannot say ──
//
// Everything structural is convention, and presence is the declaration: `sidecar/index.ts` means there is
// a sidecar, `api/router.ts` means there are routes, `db/schema.ts` means there are tables, `web/` means
// there is a frontend. The manifest carries the residue — an identity fact, or something a human chose.
//
// That is why there is no `sidecar`, `schema` or `frontend` field here, and no dock or title field either:
// the tile is `{ label, icon, color, to: mountPrefix() }` and the title is `label`, all of which are
// already below. Writing them twice could only ever drift.
//
// See docs/offscale-plugin.md for the reasoning behind each decision recorded here.
/**
* A permission the plugin adds to the platform's permission system.
*
* Called `permissions` and NOT `capabilities`: that word already means three different things in this
* codebase — the permission registry, the file-based item store under `$OFFICER_ROOT/capabilities`, and
* the routing keys a sidecar registers with. A fourth would be one too many.
*/
export type PluginPermission = {
/** Stable identifier, stored as the grant's subject. Renaming one is a data change. */
key: string;
label: string;
description: string;
/**
* Owner-only, or grantable to members. The whole distinction a plugin needs.
*
* The platform's own `CapabilityKind` has five values because the PLATFORM has five sorts of surface.
* A plugin has two states, so this is a boolean — which also removes the escalation question rather
* than answering it: a plugin cannot claim `core` if `core` is not a word it can say.
*/
ownerOnly?: boolean;
/**
* Requests that look like writes and are not — `POST /ssh-test` probes, `POST /policy/assist` proposes
* a document and never saves one. Without declaring them, a read-level account meets what reads as a
* broken feature where a withheld permission should be.
*/
readOnlyWrites?: string[];
};
export type PluginManifest = {
/**
* Who published it. The ONLY input to `mountPrefix`, so first-party and third-party can never become two
* code paths. Constant today; the seam third parties hang off later.
*/
publisher: string;
/** The plugin's own semver. Updates compare against this. */
version: string;
/** Which platform versions this build is good for. Refused at install when it does not match. */
platform: string;
label: string;
summary: string;
/** A lucide icon name, resolved at render. */
icon: string;
/** Tile colour. */
color: string;
permissions: PluginPermission[];
};
/** What the platform knows about a plugin on disk: its manifest, plus everything the tree said. */
export type DiscoveredPlugin = {
/**
* THE id — route segment, table prefix, sidecar suffix, install key.
*
* Taken from the DIRECTORY NAME rather than declared, so the id cannot disagree with where the code
* sits. The cost is that renaming a directory re-identifies the plugin; the benefit is that the two can
* never drift, and a wrong table prefix is a much quieter failure than a missing directory.
*/
appName: string;
/** Absolute path to the plugin's directory. */
dir: string;
manifest: PluginManifest;
/** `api/router.ts` — a backend router, mounted at `mountPrefix`. */
api: string | null;
/** `db/schema.ts` — tables, pushed on install. Every name must be prefixed `<appName>_`. */
schema: string | null;
/** `sidecar/index.{ts,mjs}` — a process for PM2. */
sidecar: { script: string; runtime: 'bun' | 'node' } | null;
/** `web/Router.tsx` — a frontend, mounted at `<mountPrefix>/*` by the generated Plugins.tsx. */
web: { router: string; panels: string | null } | null;
};
/**
* Where a plugin's routes live, on both the API and the frontend.
*
* `publisher` is the only input, deliberately. First-party plugins sit at the root because Officer Dev
* owns that namespace anyway and provenance is then legible at a glance in a log; third-party plugins sit
* under `/p/<publisher>/`, which is what makes it impossible for any plugin to shadow a core route — and
* therefore what lets the platform keep adding core routes forever without breaking an install.
*
* NOTHING else in the codebase may branch on provenance. If that difference leaks past this one function
* — 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.
*/
export const FIRST_PARTY_PUBLISHER = 'officerdev';
export function mountPrefix(plugin: { appName: string; manifest: { publisher: string } }): string {
const { appName } = plugin;
return plugin.manifest.publisher === FIRST_PARTY_PUBLISHER
? `/${appName}`
: `/p/${plugin.manifest.publisher}/${appName}`;
}
/** An app name has to be a URL segment, a SQL identifier prefix and a directory name at once. */
const APP_NAME_RE = /^[a-z][a-z0-9-]{0,38}$/;
/** A publisher shares the app name's constraints — it is a path segment too. */
const PUBLISHER_RE = /^[a-z][a-z0-9-]{0,38}$/;
/**
* Validate a manifest read off disk. Returns the reasons it is unusable, empty when it is fine.
*
* Returns every problem rather than the first, because an install that fails one field at a time is an
* install someone retries four times.
*/
export function manifestProblems(appName: string, manifest: Partial<PluginManifest> | null): string[] {
const problems: string[] = [];
if (!manifest) return ['no manifest, or it did not export `manifest`'];
if (!APP_NAME_RE.test(appName)) {
problems.push(`directory name "${appName}" must be lowercase letters, digits and dashes, starting with a letter`);
}
if (typeof manifest.publisher !== 'string' || !PUBLISHER_RE.test(manifest.publisher)) {
problems.push('publisher must be lowercase letters, digits and dashes');
}
for (const field of ['version', 'platform', 'label', 'summary', 'icon', 'color'] as const) {
if (typeof manifest[field] !== 'string' || !manifest[field]) problems.push(`${field} is required`);
}
if (!Array.isArray(manifest.permissions)) {
problems.push('permissions must be an array (use [] when the plugin gates nothing)');
} else {
for (const [i, permission] of manifest.permissions.entries()) {
if (!permission || typeof permission.key !== 'string' || !permission.key) {
problems.push(`permissions[${i}].key is required`);
continue;
}
// The permission key shares the capability registry's namespace, so a plugin colliding with a core
// capability would silently widen or narrow it. Prefixing is not enforced here — the installer
// checks against the live registry, which is the only thing that knows what is taken.
if (typeof permission.label !== 'string' || !permission.label) {
problems.push(`permissions[${i}].label is required`);
}
}
}
return problems;
}
+100
View File
@@ -0,0 +1,100 @@
import { listPluginInstalls, type PluginInstall } from 'officerdb';
import type { MountedPlugin } from '../hono';
import { rebuildHonoApp } from '../hono';
import { discoverPlugins } from './discover';
import { mountPrefix, type DiscoveredPlugin } from './manifest';
// Turning what is on disk plus what is in the database into a mounted application.
//
// Three states, and they are genuinely different questions:
//
// on disk the directory exists — `discoverPlugins`
// installed a `plugin_installs` row — the owner asked for it
// enabled that row says so — and has not since turned it off
//
// Only the third mounts. A plugin a developer is writing sits in the tree unmounted; a disabled plugin
// keeps every table and row it owns and simply stops answering.
/** A plugin, with whatever the database knows about it. `install` is null when nobody has installed it. */
export type PluginState = {
plugin: DiscoveredPlugin;
install: PluginInstall | null;
/** The manifest on disk moved after it was installed — normal while developing, worth being able to see. */
outdated: boolean;
};
export type PluginsSnapshot = {
states: PluginState[];
/** Directories that look like plugins and could not be read. Rendered, never thrown — see `discover.ts`. */
broken: { appName: string; error: string }[];
};
/**
* What is on disk, joined to what is installed.
*
* An install row with no directory is DROPPED rather than reported: it means the code was removed from
* the tree while the row stayed, and there is nothing to mount, describe or offer. The row is left in the
* database on purpose — deleting it here would turn "somebody moved the checkout" into silent data loss.
*/
export async function snapshotPlugins(): Promise<PluginsSnapshot> {
const [{ plugins, broken }, installs] = await Promise.all([discoverPlugins(), listPluginInstalls()]);
const byName = new Map(installs.map((row) => [row.appName, row]));
const states = plugins.map((plugin) => {
const install = byName.get(plugin.appName) ?? null;
return { plugin, install, outdated: !!install && install.version !== plugin.manifest.version };
});
return { states, broken };
}
/**
* Load a plugin's backend router.
*
* `api/router.ts` must export `router`. Anything else — a default export, a factory, a bare Hono — is
* refused by name rather than mounted wrong: a plugin whose routes silently do not exist is far harder to
* diagnose than one that refuses to install.
*/
export async function loadPluginRouter(plugin: DiscoveredPlugin): Promise<MountedPlugin | null> {
if (!plugin.api) return null;
const module = (await import(plugin.api)) as { router?: unknown };
const router = module.router;
if (!router || typeof (router as { fetch?: unknown }).fetch !== 'function') {
throw new Error(`${plugin.appName}: api/router.ts must export \`router\` (a Hono router)`);
}
return { prefix: mountPrefix(plugin), router: router as MountedPlugin['router'] };
}
/** The plugins that should be mounted right now: installed, enabled, and carrying an `api/router.ts`. */
export async function mountablePlugins(snapshot: PluginsSnapshot): Promise<MountedPlugin[]> {
const mounted: MountedPlugin[] = [];
for (const { plugin, install } of snapshot.states) {
if (!install?.enabled || !plugin.api) continue;
try {
const entry = await loadPluginRouter(plugin);
if (entry) mounted.push(entry);
} catch (err) {
// One plugin that will not load must not take the other nine down with it, and must not stop the
// platform booting. It stays unmounted and says why.
console.error(`[plugins] ${plugin.appName} not mounted:`, err instanceof Error ? err.message : err);
}
}
return mounted;
}
/**
* Rebuild the application from the current state of disk and database.
*
* This is the whole of install, uninstall, enable and disable as far as ROUTING is concerned — each of
* those writes a row and then calls this. Hono cannot add a route to a live app and cannot remove one at
* all, so nothing is mutated: a fresh app is built and `honoServer` is reassigned. `server.tsx` serves it
* through a closure, which is what makes the reassignment take effect.
*/
export async function refreshPluginMounts(): Promise<{ mounted: string[]; broken: string[] }> {
const snapshot = await snapshotPlugins();
const mounted = await mountablePlugins(snapshot);
rebuildHonoApp(mounted);
return { mounted: mounted.map((m) => m.prefix), broken: snapshot.broken.map((b) => b.appName) };
}
+249
View File
@@ -0,0 +1,249 @@
import {
appendFileSync,
closeSync,
existsSync,
openSync,
readdirSync,
readFileSync,
readSync,
rmSync,
statSync,
} from 'node:fs';
import { basename, join } from 'node:path';
import { runAsArgv } from './os-user';
// Reading a file that belongs to a member.
//
// ── Why the ACL grant is not enough ──
//
// `confineUserTree` gives the service user a named ACL entry on every member home (`u:<serviceUid>:rwx`,
// plus `d:` defaults so anything created later inherits it). That is what made the file browser work on
// 2026-08-11, and it is genuinely in force — `getfacl` on a member's home shows the entry.
//
// It does not survive contact with a file created at mode 600, because POSIX derives the ACL **mask** from
// the group bits of the creation mode, and the mask clamps every named entry:
//
// user:officer:rwx #effective:---
// mask::---
//
// `claude` writes every transcript at exactly that mode (verified: `.claude` and `projects/` are 775, every
// `*.jsonl` is 600). So the platform could list a member's transcripts and read not one byte of them — and
// `summarizeTranscript` catches EACCES and returns null, so the sessions did not fail, they *vanished*. A
// member chatted normally and their conversation list was empty on every refresh.
//
// No ACL fixes this. The creation mode ANDs the mask down, so `d:` defaults cannot raise it, and widening
// the mode would have to go through `other` — which is every account on the box. The only readers a 600 file
// has are its owner and root.
//
// ── So read as the owner of the file ──
//
// Which is what the terminal and the agent already do, through the same `runAsArgv` helper. The platform is
// the owner's process and could equally read via `sudo cat`, but acting AS the member keeps one rule instead
// of two: a member's bytes are reached through the member's identity, and the kernel stays the arbiter.
//
// Deliberately synchronous. `Bun.spawnSync` is what lets this drop into `claude-sessions.ts` — 914 lines and
// 28 functions of synchronous parsing, reached from five modules — without turning the whole read path async
// for a subprocess that takes a millisecond. The alternative was an `await` ripple through every caller for
// no behavioural gain.
//
// ── That last paragraph used to say only CONTENT needed this. It was wrong ──
//
// It claimed `statSync` and `readdirSync` were satisfied by "the 775 directories". They are not, because
// there are no 775 directories on this path: `claude` creates `~/.claude/projects/` and each project group
// at mode **700**, and the same rule that clamps a 600 file clamps a 700 directory —
//
// $ getfacl .../.claude/projects
// user:officer:rwx #effective:---
// mask::---
//
// so the service user has neither `r` nor `x` on it. Measured, not reasoned:
//
// existsSync(projects) -> true (stat needs traverse on `.claude`, which IS permissive)
// readdirSync(projects) -> EACCES
// existsSync(projects/<slug>) -> false (no `x` on projects, so it cannot even be reached)
// statSync(<transcript>) -> EACCES
//
// `existsSync` returning **false** rather than throwing is what made this invisible: every caller read it as
// "no such session" and returned an empty list or a 404. One bug, three symptoms — an empty conversation
// list, no title on a new chat, and a /chat/<id> deep link that never restored. The content fix landed
// without it because content reads were already funnelled through this file; enumeration never was.
//
// So enumeration is here too, and as ONE call rather than a spawn per entry: `readdir` + `stat` per file
// would be dozens of `sudo setpriv` forks per request, each writing a line to `/var/log/auth.log`. A single
// `find` answers the whole tree — which paths exist AND their mtimes — in one fork.
/** One transcript on disk. `slug` is the project-group directory; `id` the session uuid. */
export type TranscriptFile = { slug: string; id: string; mtimeMs: number };
/**
* Every `*.jsonl` under `projectsDir`, with mtimes, as the owner of the files.
*
* Replaces `readdirSync` + `statSync`, both of which fail for a member. Returns `[]` for a tree that does
* not exist or cannot be read — the callers all treat "no transcripts" and "cannot look" the same way, and
* there is no useful third answer to give a list endpoint.
*
* `onlySlug` narrows to one project group; it bounds the owner's syscalls and the member's `find`, and the
* result is identical either way.
*/
export function listTranscriptsAs(osUser: AsUser, projectsDir: string, onlySlug?: string): TranscriptFile[] {
if (!osUser) return listTranscriptsAsSelf(projectsDir, onlySlug);
// GNU `-printf` is safe here: the member path exists only where `sudo setpriv` does, which is Linux. An
// owner on macOS takes the branch above.
// Depth follows the root: transcripts are `projects/<slug>/<id>.jsonl`, so scanning the whole tree is two
// levels down and scanning one group is one. Pinning both bounds keeps `find` off the rest of the home.
const root = onlySlug ? join(projectsDir, onlySlug) : projectsDir;
const depth = onlySlug ? '1' : '2';
let out: string;
try {
out = runSync(osUser, [
'find',
root,
'-mindepth',
depth,
'-maxdepth',
depth,
'-name',
'*.jsonl',
'-printf',
'%h\t%f\t%T@\n',
]);
} catch {
// A missing tree exits non-zero, which is the same nothing as an empty one.
return [];
}
const files: TranscriptFile[] = [];
for (const line of out.split('\n')) {
if (!line) continue;
const [dir, name, mtime] = line.split('\t');
if (!dir || !name || !mtime) continue;
files.push({ slug: basename(dir), id: name.replace(/\.jsonl$/, ''), mtimeMs: Math.round(Number(mtime) * 1000) });
}
return files;
}
/** The owner's own files — no fork, because this process already IS them. */
function listTranscriptsAsSelf(projectsDir: string, onlySlug?: string): TranscriptFile[] {
const slugs = onlySlug ? [onlySlug] : safeReaddir(projectsDir);
const files: TranscriptFile[] = [];
for (const slug of slugs) {
for (const name of safeReaddir(join(projectsDir, slug))) {
if (!name.endsWith('.jsonl')) continue;
try {
files.push({
slug,
id: name.replace(/\.jsonl$/, ''),
mtimeMs: statSync(join(projectsDir, slug, name)).mtimeMs,
});
} catch {
// Raced with a delete, or not a regular file. Either way it is not a transcript we can offer.
}
}
}
return files;
}
const safeReaddir = (dir: string): string[] => {
try {
return readdirSync(dir);
} catch {
return [];
}
};
/** Whose identity to read as. `null` is this process's own uid — the owner, and the common case. */
export type AsUser = string | null;
/** `Bun.spawnSync` through `setpriv`, or a throw carrying enough to tell EACCES from ENOENT. */
function runSync(osUser: string, command: string[]): string {
const result = Bun.spawnSync(runAsArgv(osUser, command), { stdout: 'pipe', stderr: 'pipe' });
if (result.exitCode !== 0) {
const detail = new TextDecoder().decode(result.stderr).trim() || `exit ${result.exitCode}`;
throw new Error(`reading as ${osUser} failed: ${detail}`);
}
return new TextDecoder().decode(result.stdout);
}
/** The whole file, as text. Throws on any failure, so existing `try`/`catch` around reads keeps working. */
export function readTextAs(osUser: AsUser, path: string): string {
if (!osUser) return readFileSync(path, 'utf-8');
return runSync(osUser, ['cat', '--', path]);
}
/** The first `bytes` bytes. Used where a header is all that is wanted and transcripts run to megabytes. */
export function readHeadAs(osUser: AsUser, path: string, bytes: number): string {
if (!osUser) return readRange(path, 0, bytes);
return runSync(osUser, ['head', '-c', String(bytes), '--', path]);
}
/** The last `bytes` bytes. `truncated` reports whether anything was left off the front. */
export function readTailAs(osUser: AsUser, path: string, bytes: number): { text: string; truncated: boolean } {
if (!osUser) {
const size = statSync(path).size;
return { text: readRange(path, Math.max(0, size - bytes), bytes), truncated: size > bytes };
}
// `statSync` is EACCES on a member's transcript — the header explains why — so the size has to come back
// from the same identity as the bytes. One fork for both: `wc -c` writes the size on the first line, then
// `tail` writes the window. Splitting them would double the forks and could straddle an append.
const out = runSync(osUser, ['sh', '-c', 'wc -c < "$1"; tail -c "$2" -- "$1"', '_', path, String(bytes)]);
const firstBreak = out.indexOf('\n');
const size = Number(out.slice(0, firstBreak).trim());
return { text: out.slice(firstBreak + 1), truncated: Number.isFinite(size) && size > bytes };
}
/** Append one line, as its owner. The rename path writes a `summary` entry into the member's transcript. */
export function appendTextAs(osUser: AsUser, path: string, text: string): void {
if (!osUser) {
appendFileSync(path, text);
return;
}
// `tee -a` rather than a shell redirect: no shell means no quoting question about the path.
const result = Bun.spawnSync(runAsArgv(osUser, ['tee', '-a', '--', path]), {
stdin: new TextEncoder().encode(text),
stdout: 'ignore',
stderr: 'pipe',
});
if (result.exitCode !== 0) {
const detail = new TextDecoder().decode(result.stderr).trim() || `exit ${result.exitCode}`;
throw new Error(`appending as ${osUser} failed: ${detail}`);
}
}
/**
* Delete a file as its owner. Returns false if it was not there.
*
* Needed for the same reason the listing is: removing an entry needs `w`+`x` on the DIRECTORY, and the
* service user has neither on a member's `projects/<slug>`. Without this, deleting a member's conversation
* silently removed nothing and still answered `{ ok: true }`.
*/
export function removeAs(osUser: AsUser, path: string): boolean {
if (!osUser) {
if (!existsSync(path)) return false;
rmSync(path);
return true;
}
// `rm -f` exits 0 on a missing file, so absence is reported by testing first — in the same fork.
const out = runSync(osUser, [
'sh',
'-c',
'if test -e "$1"; then rm -f -- "$1" && printf 1; else printf 0; fi',
'_',
path,
]);
return out.trim() === '1';
}
/** Owner fast path for a byte window — the same positional read the callers used before this file existed. */
function readRange(path: string, start: number, bytes: number): string {
let fd: number | undefined;
try {
fd = openSync(path, 'r');
const buf = Buffer.alloc(bytes);
const n = readSync(fd, buf, 0, bytes, start);
return buf.toString('utf-8', 0, n);
} finally {
if (fd !== undefined) closeSync(fd);
}
}
+130 -5
View File
@@ -1,7 +1,8 @@
import { existsSync } from 'node:fs';
import { spawn } from 'node:child_process';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { query, type Query } from '@anthropic-ai/claude-agent-sdk';
import { query, type Query, type SpawnOptions, type SpawnedProcess } from '@anthropic-ai/claude-agent-sdk';
import type { ChatEvent, PromptImage } from '../../api/chat/types';
import type { ClaudeSpawnParams, ClaudeSpawnStreamingParams, ClaudeCodeResult, LiveClaudeSession } from '../protocol';
import { setClaudeSession, clearClaudeSession, getClaudeSession } from './state';
@@ -33,6 +34,39 @@ function resolveClaudeBin(): string {
const CLAUDE_BIN = resolveClaudeBin();
console.log(`[claude] CLI resolved to ${CLAUDE_BIN}`);
/**
* Variables the `claude` CLI injects to describe ITS OWN session, which must never reach a `claude` we
* spawn ourselves.
*
* They arrive here by an ordinary accident: 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. On 2026-08-14 this process was carrying
* `CLAUDE_CODE_MESSAGING_SOCKET` for an unrelated PID that had been alive for an hour and a half.
*
* Only three of these were stripped before (`CLAUDECODE`, `CLAUDE_CODE_ENTRYPOINT`, `CLAUDE_CODE_SSE_PORT`);
* the rest are newer and arrived with 2.x. Stripping them is hygiene rather than a fix — a spawn was
* verified to succeed with the whole set present — but the failure it prevents is a child attaching to a
* stranger's IPC socket, which would be extremely hard to recognise from the symptom.
*/
const NESTED_SESSION_ENV = [
'CLAUDECODE',
'CLAUDE_CODE_ENTRYPOINT',
'CLAUDE_CODE_SSE_PORT',
'CLAUDE_CODE_CHILD_SESSION',
'CLAUDE_CODE_MESSAGING_SOCKET',
'CLAUDE_CODE_MESSAGING_TOKEN',
'CLAUDE_CODE_SESSION_ID',
'CLAUDE_CODE_EXECPATH',
'CLAUDE_PID',
] as const;
/** `process.env` minus the parent session's fingerprint. */
function envWithoutParentSession(): Record<string, string> {
const out: Record<string, string> = { ...process.env } as Record<string, string>;
for (const name of NESTED_SESSION_ENV) delete out[name];
return out;
}
// Capture original HOME before user-instance overrides it
const HOST_HOME = process.env.HOME!;
import { DATA_PATH } from '../../data-path';
@@ -216,6 +250,79 @@ const COMPACT_STALL_TIMEOUT_MS = 20 * 60 * 1000;
const sessions = new Map<string, PersistentSession>();
/**
* The owner's spawn — what the SDK would do by default, written out so it can be wrapped by `watchChild`.
*
* A member's turn already supplies its own (`spawnClaudeAsMember`) because it has to go through `setpriv`.
* The owner had no such function and therefore no place to observe the child, which is precisely why its
* death was invisible. The existence check mirrors the SDK's default: without it a bad `CLAUDE_BIN` fails
* as a write to a closed pipe several seconds later, naming nothing useful.
*/
function spawnClaudeAsOwner({ command, args, cwd, env, signal }: SpawnOptions): SpawnedProcess {
if (!existsSync(command)) throw new Error(`claude CLI not found at ${command}`);
const child = spawn(command, args, { cwd, env, signal, stdio: ['pipe', 'pipe', 'pipe'] });
// Non-null by construction: 'pipe' on all three. Mirrors the cast in `spawn-as-member.ts`.
return child as unknown as SpawnedProcess;
}
/**
* Drop a session whose `claude` process is gone, and tell whoever was waiting.
*
* This closes the hole that made a dead agent look like a slow one **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
* an `unhandledRejection` line in the sidecar log.
*
* Observed on 2026-08-14; the session had to be cleared with `pm2 restart officer-claude-code`.
*
* Emitting only while a turn is in flight 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 then builds a fresh session and resumes the
* transcript by id.
*/
function dropDeadSession(session: PersistentSession, detail: string): void {
// Identity, not key: a later turn may have already replaced this entry, and killing its session because
// its predecessor's process exited would break the live conversation instead of a dead one.
if (sessions.get(session.sessionKey) !== session) return;
sessions.delete(session.sessionKey);
if (session.idleTimer) clearTimeout(session.idleTimer);
if (session.stallTimer) clearTimeout(session.stallTimer);
session.idleTimer = undefined;
session.stallTimer = undefined;
const wasGenerating = session.isGenerating;
session.isGenerating = false;
// A deliberate teardown (kill / idle GC) aborts first, and its exit is not news.
if (session.abort.signal.aborted) return;
console.error(`[claude:exit:${session.sessionKey}] ${detail}`);
if (!wasGenerating) return;
session.emit({
type: 'error',
message: 'The agent process exited unexpectedly. Your conversation is safe — send again to continue.',
});
}
/** Wrap a spawn so the session self-heals when its child goes away. */
function watchChild(
inner: (options: SpawnOptions) => SpawnedProcess,
session: PersistentSession,
): (options: SpawnOptions) => SpawnedProcess {
return (options: SpawnOptions): SpawnedProcess => {
const child = inner(options);
child.on('exit', (code, signal) =>
dropDeadSession(session, `claude exited (code=${code ?? 'null'} signal=${signal ?? 'null'})`),
);
child.on('error', (err: Error) => dropDeadSession(session, `claude failed to start: ${err.message}`));
return child;
};
}
/** A hand-rolled async iterable we can push turns onto and close on teardown. */
function makeInputQueue() {
const buf: SdkUserMessage[] = [];
@@ -273,13 +380,25 @@ function armStall(session: PersistentSession): void {
session.isGenerating = false;
session.compactStartedAt = undefined;
session.interrupted = false;
if (session.pendingTasks.size === 0) armIdle(session);
session.emit({
type: 'error',
message: compacting
? `Compaction has produced nothing for ${waited} minutes and has been given up on. Your conversation is safe — send again to continue.`
: `The agent has produced nothing for ${waited} minutes and has been given up on. Your conversation is safe — send again to continue.`,
});
// A background job can be silent for far longer than this and still land its `task_notification`, so
// a stall with tasks outstanding keeps the old behaviour and leaves the session alone.
if (session.pendingTasks.size > 0) return;
// Otherwise tear it down rather than leaving it armed for the next turn.
//
// This used to keep the session — "it may still be working, and the next turn resumes it" — which is
// the right instinct for a SLOW agent and exactly wrong for a wedged one: a session that has said
// nothing for ten minutes because its transport is broken stays broken, so every later turn hangs
// the same way and the message above ("send again to continue") is a lie. Killing costs a resume,
// which is what the message already promises; the transcript id survives in `claudeSessions`, so the
// next turn continues the same conversation.
killClaudeSession(session.sessionKey, session.userId);
},
compacting ? COMPACT_STALL_TIMEOUT_MS : STALL_TIMEOUT_MS,
);
@@ -318,7 +437,7 @@ function createSession(params: ClaudeSpawnStreamingParams, onEvent: (event: Chat
};
// Strip the nested-session guard vars so the SDK can spawn `claude` (mirrors the old spawn env clean).
const { CLAUDECODE: _c, CLAUDE_CODE_ENTRYPOINT: _e, CLAUDE_CODE_SSE_PORT: _s, ...cleanEnv } = process.env;
const cleanEnv = envWithoutParentSession();
const resumeId = getClaudeSession(sessionKey, params.userId) ?? params.resumeSessionId;
const subModel = params.model?.split('/')[1];
@@ -361,12 +480,18 @@ function createSession(params: ClaudeSpawnStreamingParams, onEvent: (event: Chat
// authoritative for settings, and `~` is decided by the HOME the process gets. Pointing the SDK at a
// member's binary while spawning as the service user would read the OWNER'S settings and credential
// while executing the member's code — the worst of both, and it would look like it worked.
//
// Both branches go through `watchChild`: the owner's spawn exists only so there is something to
// wrap (see `spawnClaudeAsOwner`), because a child nobody watches is a session that can die silently.
...(params.member
? {
pathToClaudeCodeExecutable: claudeBinIn(params.member.home),
spawnClaudeCodeProcess: spawnClaudeAsMember(params.member),
spawnClaudeCodeProcess: watchChild(spawnClaudeAsMember(params.member), session),
}
: { pathToClaudeCodeExecutable: CLAUDE_BIN }),
: {
pathToClaudeCodeExecutable: CLAUDE_BIN,
spawnClaudeCodeProcess: watchChild(spawnClaudeAsOwner, session),
}),
settingSources: ['user', 'project', 'local'],
env: cleanEnv as Record<string, string>,
stderr: (d: string) => {
+2 -1
View File
@@ -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
+5 -2
View File
@@ -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('') });
+17 -3
View File
@@ -21,7 +21,21 @@ import { getHomeDir, getOwnerHomeDir } from './data-path';
// No Linux account means no confinement means no access, and the refusal names the fix.
export type HomeResolution =
| { ok: true; home: string; isOwner: boolean }
| {
ok: true;
home: string;
isOwner: boolean;
/**
* The Linux account whose identity reaches this home, or `null` for the owner — who IS this process's
* uid, so there is nobody to become.
*
* Carried because resolving the home is not enough to READ inside it: a member's files are theirs and
* `claude` writes transcripts at mode 600, which clamps the platform's ACL entry to nothing. See
* `read-as-user.ts`. Reported here rather than looked up again at each call site so that "whose home"
* and "whose identity" cannot drift apart — they are one answer from one row.
*/
osUser: string | null;
}
| { ok: false; reason: string; needsOsAccount: boolean };
/**
@@ -45,7 +59,7 @@ export async function resolveHomeDir(userId: number): Promise<HomeResolution> {
// The owner runs in their real login home — the whole point of HOME_DIR, and what makes platform
// terminals share config and credentials with the shell they use outside Officer.
if (user.role === 'Super Admin') {
return { ok: true, home: getOwnerHomeDir(user.email), isOwner: true };
return { ok: true, home: getOwnerHomeDir(user.email), isOwner: true, osUser: null };
}
if (!user.osUser) {
@@ -59,5 +73,5 @@ export async function resolveHomeDir(userId: number): Promise<HomeResolution> {
// `getHomeDir` and `osUserHome` are deliberately the same path: DATA_PATH/<email>/home is both the
// managed home the platform provisions and the real passwd home of the Linux account. If those ever
// diverge, a member's shell and their file browser would show different directories.
return { ok: true, home: getHomeDir(user.email), isOwner: false };
return { ok: true, home: getHomeDir(user.email), isOwner: false, osUser: user.osUser };
}
@@ -1,4 +1,5 @@
import { appRegistryMetas as appStoreMetas } from '../apps/AppStore';
import { appRegistryMetas as pluginsMetas } from '../apps/Plugins';
import { appRegistryMetas as fileBrowserMetas } from '../apps/FileBrowser';
import { appRegistryMetas as terminalMetas } from '../apps/Terminal';
import { appRegistryMetas as codeEditorMetas } from '../apps/CodeEditor';
@@ -45,6 +46,7 @@ export const apps = [
...qrTransferMetas,
...davMetas,
...appStoreMetas,
...pluginsMetas,
];
/**
@@ -23,7 +23,6 @@ export const Toolbar = ({ fileBrowserManager }: ToolbarProps) => {
setViewMode,
setShowVideoDownload,
setShowDictate,
hiddenForced,
} = fileBrowserManager;
return (
@@ -124,11 +123,10 @@ export const Toolbar = ({ fileBrowserManager }: ToolbarProps) => {
</div>
<button
onClick={() => setShowHidden((v) => !v)}
disabled={hiddenForced}
className={`hidden md:block p-1.5 rounded-md transition-colors ${hiddenForced ? 'opacity-30 cursor-not-allowed' : `cursor-pointer ${showHidden && !hiddenForced ? 'bg-duck-teal text-duck-yellow' : 'text-duck-teal hover:bg-duck-dark/5'}`}`}
title={hiddenForced ? 'Hidden files not shown in home directory' : showHidden ? 'Hide hidden files' : 'Show hidden files'}
className={`hidden md:block p-1.5 rounded-md cursor-pointer transition-colors ${showHidden ? 'bg-duck-teal text-duck-yellow' : 'text-duck-teal hover:bg-duck-dark/5'}`}
title={showHidden ? 'Hide hidden files' : 'Show hidden files'}
>
{showHidden && !hiddenForced ? <Eye className="h-4 w-4" /> : <EyeOff className="h-4 w-4" />}
{showHidden ? <Eye className="h-4 w-4" /> : <EyeOff className="h-4 w-4" />}
</button>
<div className="flex items-center border border-duck-dark/20 rounded-md overflow-hidden">
<button
@@ -97,8 +97,7 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa
filesRef.current = files;
const currentPathRef = useRef(currentPath);
currentPathRef.current = currentPath;
const hiddenForced = currentPath === '/';
const visibleEntries = showHidden && !hiddenForced ? entries : entries.filter((e) => !e.name.startsWith('.'));
const visibleEntries = showHidden ? entries : entries.filter((e) => !e.name.startsWith('.'));
const entryPath = (name: string) => (currentPath === '/' ? `/${name}` : `${currentPath}/${name}`);
const selectedPaths = () => Array.from(selected).map(entryPath);
@@ -676,7 +675,6 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa
setViewMode,
showHidden,
setShowHidden,
hiddenForced,
// Selection
selected,
setSelected,
@@ -0,0 +1,131 @@
import { useSearchParams } from 'react-router';
import { usePlugins, type PluginItem } from './usePlugins';
// The right panel: one plugin, and the four verbs.
//
// Reads `?selected=` itself rather than being handed a plugin by the list — neither panel tells the other
// anything, so they cannot disagree.
const Row = ({ label, children }: { label: string; children: React.ReactNode }) => (
<div className="flex gap-3 py-1.5 text-sm">
<span className="w-28 shrink-0 text-duck-dark/50">{label}</span>
<span className="min-w-0 text-duck-dark">{children}</span>
</div>
);
const Button = ({
onClick,
disabled,
tone = 'ghost',
children,
}: {
onClick: () => void;
disabled?: boolean;
tone?: 'primary' | 'ghost' | 'danger';
children: React.ReactNode;
}) => {
const tones = {
primary: 'bg-duck-teal text-duck-yellow hover:opacity-90',
ghost: 'border border-duck-dark/20 text-duck-dark hover:bg-duck-dark/5',
danger: 'border border-red-300 text-red-600 hover:bg-red-50',
};
return (
<button
onClick={onClick}
disabled={disabled}
className={`rounded-md px-3 py-1.5 text-sm transition-colors disabled:opacity-40 ${tones[tone]}`}
>
{children}
</button>
);
};
/** What the tree declared. Shown because "installed but nothing happened" is otherwise a mystery. */
const Parts = ({ has }: { has: PluginItem['has'] }) => {
const parts = [
['api', has.api],
['schema', has.schema],
['sidecar', has.sidecar],
['web', has.web],
] as const;
const present = parts.filter(([, yes]) => yes).map(([name]) => name);
return <>{present.length ? present.join(' · ') : 'manifest only'}</>;
};
export const PluginDetail = () => {
const { plugins, install, uninstall, enable, disable } = usePlugins();
const [params] = useSearchParams();
const plugin = plugins.find((p) => p.appName === params.get('selected'));
if (!plugin) {
return <div className="p-6 text-sm text-duck-dark/50">Select a plugin.</div>;
}
const busy = install.isPending || uninstall.isPending || enable.isPending || disable.isPending;
return (
<div className="h-full overflow-auto p-6">
<h2 className="text-lg font-semibold text-duck-dark">{plugin.label}</h2>
<p className="mt-1 text-sm text-duck-dark/60">{plugin.summary}</p>
<div className="mt-5 border-t border-duck-dark/10 pt-4">
<Row label="Mounts at">
<code>/api{plugin.prefix}</code>
</Row>
<Row label="Publisher">{plugin.publisher}</Row>
<Row label="Version">
{plugin.version}
{plugin.outdated ? (
<span className="ml-2 text-amber-600">on disk installed {plugin.installedVersion}</span>
) : null}
</Row>
<Row label="Needs platform">{plugin.platform}</Row>
<Row label="Ships">
<Parts has={plugin.has} />
</Row>
<Row label="Permissions">
{plugin.permissions.length
? plugin.permissions.map((p) => `${p.key}${p.ownerOnly ? ' (owner only)' : ''}`).join(', ')
: 'none — reachable by anyone who can reach the platform'}
</Row>
</div>
<div className="mt-6 flex flex-wrap gap-2">
{!plugin.installed ? (
<Button tone="primary" disabled={busy} onClick={() => install.mutate(plugin.appName)}>
Install
</Button>
) : (
<>
{plugin.enabled ? (
<Button disabled={busy} onClick={() => disable.mutate(plugin.appName)}>
Disable
</Button>
) : (
<Button tone="primary" disabled={busy} onClick={() => enable.mutate(plugin.appName)}>
Enable
</Button>
)}
{plugin.outdated ? (
<Button disabled={busy} onClick={() => install.mutate(plugin.appName)}>
Update to {plugin.version}
</Button>
) : null}
<Button tone="danger" disabled={busy} onClick={() => uninstall.mutate(plugin.appName)}>
Uninstall
</Button>
</>
)}
</div>
{/* Uninstall keeps every table and row the plugin owns, so this is worth saying rather than
leaving someone to guess whether the button destroys their data. */}
{plugin.installed ? (
<p className="mt-4 text-xs text-duck-dark/40">
Disabling unmounts its routes and stops its sidecar. Uninstalling also forgets the install neither deletes
anything the plugin stored.
</p>
) : null}
</div>
);
};
@@ -0,0 +1,63 @@
import { Link, useSearchParams } from 'react-router';
import { Puzzle, AlertTriangle } from 'lucide-react';
import { usePlugins, type PluginItem } from './usePlugins';
// The left panel: every plugin in the tree, installed or not.
//
// Rows are real `<Link>`s carrying `?selected=`, not buttons with the name in a closure — so cmd-click,
// middle-click and "copy link" all work, and the detail panel reads the URL rather than being told.
// See docs/navigation-audit.md on the opaque-click anti-pattern.
const Status = ({ plugin }: { plugin: PluginItem }) => {
if (!plugin.installed) return <span className="text-xs text-duck-dark/40">not installed</span>;
if (!plugin.enabled) return <span className="text-xs text-amber-600">disabled</span>;
if (plugin.outdated) return <span className="text-xs text-amber-600">update available</span>;
return <span className="text-xs text-emerald-600">enabled</span>;
};
export const PluginsList = () => {
const { plugins, broken, isLoading } = usePlugins();
const [params] = useSearchParams();
const selected = params.get('selected');
if (isLoading) return <div className="p-4 text-sm text-duck-dark/50">Loading</div>;
return (
<div className="h-full overflow-auto">
{plugins.length === 0 && broken.length === 0 ? (
<div className="p-4 text-sm text-duck-dark/50">
No plugins in <code>plugins/</code> yet.
</div>
) : null}
{plugins.map((plugin) => (
<Link
key={plugin.appName}
to={`/plugins?selected=${encodeURIComponent(plugin.appName)}`}
className={`flex items-center gap-3 px-3 py-2.5 border-b border-duck-dark/5 transition-colors ${
selected === plugin.appName ? 'bg-duck-teal/10' : 'hover:bg-duck-dark/5'
}`}
>
<Puzzle className="h-4 w-4 shrink-0" style={{ color: plugin.color }} />
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-duck-dark">{plugin.label}</div>
<div className="truncate text-xs text-duck-dark/50">{plugin.prefix}</div>
</div>
<Status plugin={plugin} />
</Link>
))}
{/* A directory that could not be read is shown rather than swallowed — otherwise a malformed
manifest looks exactly like a plugin nobody wrote. */}
{broken.map((b) => (
<div key={b.appName} className="flex items-start gap-3 px-3 py-2.5 border-b border-duck-dark/5">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-red-500" />
<div className="min-w-0">
<div className="truncate text-sm font-medium text-duck-dark">{b.appName}</div>
<div className="text-xs text-red-600">{b.error}</div>
</div>
</div>
))}
</div>
);
};
@@ -0,0 +1,17 @@
import { Puzzle } from 'lucide-react';
import type { AppRegistryMeta } from '../../AppRegistry';
import { PluginsList } from './PluginsList';
import { PluginDetail } from './PluginDetail';
export { PluginsList } from './PluginsList';
export { PluginDetail } from './PluginDetail';
export { usePlugins } from './usePlugins';
export type { PluginItem, PluginPermission } from './usePlugins';
// Two panels, read side by side, neither telling the other anything — the selection is `?selected=` and
// both read it. `availableOnPanel: false` keeps them off the generic picker: they only make sense on
// /plugins, together.
export const appRegistryMetas: AppRegistryMeta[] = [
{ key: 'plugins-list', name: 'Plugins', icon: Puzzle, component: PluginsList, availableOnPanel: false },
{ key: 'plugin-detail', name: 'Plugin detail', icon: Puzzle, component: PluginDetail, availableOnPanel: false },
];
@@ -0,0 +1,81 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useClient } from 'hooks/useClient';
// Reading and driving the plugin system. One query, four verbs.
//
// Not the app store. That installs sidecars from a compiled-in catalogue, provisioning containers and
// asking questions; this installs plugins from the tree and asks nothing.
export type PluginPermission = { key: string; label: string; description: string; ownerOnly?: boolean };
export type PluginItem = {
appName: string;
/** Where its routes live. `/offscale` for ours, `/p/<publisher>/<name>` for everyone else. */
prefix: string;
label: string;
summary: string;
icon: string;
color: string;
publisher: string;
version: string;
platform: string;
permissions: PluginPermission[];
/** What the directory declared. Shown so "installed but does nothing" is legible rather than puzzling. */
has: { api: boolean; schema: boolean; sidecar: boolean; web: boolean };
installed: boolean;
enabled: boolean;
installedVersion: string | null;
/** The code on disk moved after it was installed — normal while developing, and worth seeing. */
outdated: boolean;
};
export type BrokenPlugin = { appName: string; error: string };
const PLUGINS_KEY = ['plugins'];
export function usePlugins() {
const client = useClient();
const queryClient = useQueryClient();
const { data, isLoading, error } = useQuery({
queryKey: PLUGINS_KEY,
queryFn: () => client.get<{ plugins: PluginItem[]; broken: BrokenPlugin[] }>('/plugins'),
});
// Every verb invalidates the plugin list AND self-capabilities: installing a plugin can add a dock tile
// and a route the shell has to know about, so refreshing one without the other leaves the two disagreeing.
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: PLUGINS_KEY });
queryClient.invalidateQueries({ queryKey: ['self-capabilities'] });
};
// 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.
const install = useMutation({
mutationFn: (appName: string) => client.post(`/plugins/${appName}/install`, {}),
onSuccess: invalidate,
});
const uninstall = useMutation({
mutationFn: (appName: string) => client.post(`/plugins/${appName}/uninstall`, {}),
onSuccess: invalidate,
});
const enable = useMutation({
mutationFn: (appName: string) => client.post(`/plugins/${appName}/enable`, {}),
onSuccess: invalidate,
});
const disable = useMutation({
mutationFn: (appName: string) => client.post(`/plugins/${appName}/disable`, {}),
onSuccess: invalidate,
});
return {
plugins: data?.plugins ?? [],
broken: data?.broken ?? [],
isLoading,
error,
install,
uninstall,
enable,
disable,
};
}