cac58ea044336261c7a258e826e11ecbffcc4265
99
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
d273dc6a14 |
install a plugin from a git URL, with no registry involved
the catalogue was the only way a remote plugin could arrive, so "build one and give it to a friend" had no route. the first real case is a plugin that must never be listed: fofos/fofos-admin is one website's admin panel, useful to exactly one person. MUCH smaller than the todo assumed. fetchPlugin already cloned, validated the URL, refused to overwrite an existing directory, checked for a manifest and removed a failed clone. exactly one line tied it to the marketplace — where the string came from. so this is a source parameter threaded through, and a route. THE NAME COMES FROM THE REPOSITORY, because appName is the DIRECTORY name and is never declared in a manifest — the id cannot disagree with where the code sits. that decision is inherited here rather than remade: the repo name becomes the directory, the route segment, the table prefix and the sidecar suffix. so it is checked against ^[a-z0-9]+(-[a-z0-9]+)*$ BEFORE anything is cloned; a half-clone under an unusable name reads as a broken plugin rather than an absent one. the URL is validated identically whether it came from the catalogue or from a person, because the catalogue is not a trust boundary — it is just where a string came from, and the same string reaches the same argv. what differs is who may ask: this route is owner-only, and the owner is choosing what code runs rather than choosing from a list somebody curated. validation is not review, and nothing here reads what it cloned. caught by testing: the source reached installPlugin and stopped there — the fetchPlugin call still had the old two arguments, so a URL install fell back to the catalogue and failed with "no catalogue entry". the guard tests are what surfaced it, because the error named the wrong cause. verified end to end against fofos-admin — cloned, schema applied, mounted, dock pinned — then purged, since officer-dev is not that website's machine. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
68f3643df7 |
a pull leaves origin/master where it found it, so every checkout looked ahead
plugins:check reported "2 commit(s) NOT PUSHED" for all twenty-one installed plugins on a machine where nothing had been committed. the check was right about what it measured and the cause was here. `git fetch origin HEAD` writes FETCH_HEAD and nothing else. refs/remotes/origin/ <branch> keeps whatever it had, so after a successful pull the checkout sits at the newest commit while origin/master still points at an older one — and every `rev-list origin/master...HEAD` reports the checkout as ahead by however many commits it just pulled. so the remote-tracking ref is refreshed too, explicitly, after the fetch that cannot do it. best effort, because the local branch may not exist on the remote under that name — which is the same reason the fetch asks for HEAD rather than for a branch. fetch also asks for --tags now. tags are how releaseState decides whether there is anything to update to, so a fetch that skipped them left the Update button answering from refs older than the release it was offering. verified by staling a ref on purpose: one false positive before, none after a single pullPlugin. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
68d6dd942e |
update means something now: it depends on tags
"update" was `git pull` and re-install, unconditionally, for every plugin.
nothing could say whether there was anything to pull, so the button was always
there and usually did nothing — and "update all" was twenty-one no-ops with a
spinner on it. the button's own comment admitted it: gating on `outdated` would
hide it for ever, because nothing bumped a version.
versions are dated and tagged now, so the rule the owner asked for is possible:
· a checkout NOT at a tag is always updateable — it is between releases, no
claim is being made about it, pulling is always reasonable
· a checkout AT a tag is updateable only when a NEWER tag exists
the part worth defending is how "newer" is decided. the obvious version-string
comparison is wrong the first time a plugin is tagged `v2` or `1.0.0-rc1`, and
wrong SILENTLY — a plugin that never offers an update looks like one nobody has
released. so newness is asked of git: the newest tag reachable from the REMOTE
branch, compared against the tag at HEAD. that holds for any names anyone
chooses, and there is a test with a tag called `beta` that sorts before `v1` to
keep it honest.
it never fetches. every answer comes from refs already on disk, because this
runs once per plugin on a request that renders a list and twenty-one network
round trips would make the screen feel broken. tags arrive at clone and at the
fetch `pullPlugin` already does.
the VERB refuses too, not just the UI. a plugins list is fetched once and a tag
can be pushed a second later, so the copy someone clicks is routinely older than
the truth — and `updateAll` iterates a list rather than asking per plugin.
"up to date · <tag>" is said out loud rather than shown as an absent button. no
button and up-to-date look identical, and only one of them is information.
verified on the real installs: example pulled to its tag and flipped to
up-to-date, caldav on an older commit stayed updateable, and updatePlugin on
example answered "already at 2026.08.27.01, the newest release" without pulling.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
|
||
|
|
d58f58813c |
the marketplace is browsed by tag, not scrolled
/marketplace was a list of every plugin on the left and a preview on the right. that is a fine control panel and a poor shop: twenty-one rows sorted by nothing anybody cares about, where "something for my calendar" means reading all of them. now: tags down the left with counts, cards in a grid on the right, and clicking a card takes over the panel with a back link. three things about the shape. BOTH the filter and the selection are in the URL — ?tag= and ?selected=. a filter in component state is lost on Back, so filtering, opening a plugin and pressing Back would drop you in an unfiltered grid having lost your place. browser Back works for free for the same reason. selecting a tag also CLEARS the selection, because a detail for a plugin outside the new filter is a page the grid you are now looking at cannot return to. the detail REPLACES the grid rather than sitting beside it. a grid needs the width and a detail beside one gets half of it; neither reads well. the back link drops ?selected= and keeps ?tag=, which is the entire point of putting the filter there. the panel KEYS are unchanged though both components were replaced. a stored layout names them, and renaming would drop everyone back to the default arrangement for nothing — the key identifies a slot, not an implementation. two things found on the way. PluginItem and AvailablePlugin had no tags, so the api now carries them on both branches — from the manifest for a plugin that is here, from the catalogue for one that is not, same field either way. and the screen used useDashboardState, which persists the whole tree: the panels this ships would never have reached anyone who had opened it before. useAuthoredLayout takes the structure from source and persists only sizes, which is what an authored screen wants. 21/22 -> 22/78 as well: the left panel holds seven tags now, not twenty-one rows, and the width is worth more to the grid. verified: bundle contains the new panels, api returns tags for all 21 with none missing the field. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
d381140d54 |
plugins say what they are for, and their checkouts stop lying about themselves
two things, and the second is why the first was painful. TAGS. the marketplace is going to be browsed rather than scrolled: tags on the left, the plugins carrying one on the right. a plugin declares its own in the manifest and the catalogue caches it on sync, exactly as it already does for label and summary — the alternative is editing a table every time a plugin ships, and a plugin that cannot say what it is. the vocabulary is CLOSED (PLUGIN_TAGS) because free text becomes homelab, home-lab, Home Lab and selfhosted inside a month, and a filter list of synonyms is worse than no filter. an unknown tag is refused at discovery BY NAME, so the author is told which tag rather than which array index. all 21 are tagged, each with a one-line note saying why, so a later disagreement is with a stated reason rather than a mystery. every tag has at least three plugins behind it — no filter opens onto an empty page. PLUGINS:CHECK. getting one commit into 21 repositories failed seven times and none of the reasons were about the commit: six remotes were HTTPS and cannot authenticate unattended, three checkouts were behind their remote, one URL had no .git suffix so the catalogue published a clone URL unlike every other, and offscale's fetch refspec named a branch that does not exist. that last one is why this is a script and not a habit. every command reported on a phantom — `git push` said "Everything up-to-date" while the branch looked diverged — and the repository was fine the whole time. twenty minutes spent in a repo with nothing wrong with it. none of it is visible until you try to push, which is the worst moment to learn it: mid-task, across 21 repositories, and every failure looks like it is about your change. so it checks the facts with one right answer — remote URL, fetch refspec, branch, upstream, dirty tree, ahead/behind — and exits non-zero. `--fix` is a separate flag so running the check can never be what changes your tree, and divergence is never auto-repaired because it needs a decision about content. verified by breaking each case on purpose and watching it catch them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
52f209c69f |
caldav leaves the platform
calendar and contacts are a plugin now — plugins/caldav, its own repository like every other one. a fresh machine runs five processes instead of seven and gets a calendar by installing one plugin, which is the whole point: the platform stops carrying a python calendar server for people who may not want one. what left: the radicale sidecar, four routers, the panel apps, the dav_app_passwords table, the two screens, the two dock tiles, the permission registry entry, the settings section, and radicale from every package list. three platform things had to change first, each committed on its own: |
||
|
|
e0df28ff5d |
a first-party plugin may claim a root path the platform reserved
caldav is not only /api/caldav. four of its surfaces are top-level and above the account gate on purpose, because DAVx5, iOS and Thunderbird send HTTP Basic on every request and have nowhere to put a JWT: /dav, the ios profile under it, and the two .well-known discovery paths a client probes before it has any credential. the mechanism for that already existed — unauthenticatedRootPaths, exclusive claims, first come wins. what stopped caldav using it was RESERVED_ROOT, and what that list was recording is worth being exact about: `dav` and `.well-known` are in it because the PLATFORM serves them today, not because they are dangerous in themselves. once caldav is a plugin, the thing the reservation protects IS the plugin, and keeping it would mean the platform permanently owning routes for a feature it no longer contains. so a reserved name is claimable by a first-party plugin and refused to everyone else. the owner drew that line today: fine for a plugin we wrote, and letting a stranger's plugin claim /dav is a question that needs thinking about properly rather than falling out of a flag. /api stays refused to us too — everything of officer's lives under it and protocolHeaders is the only sanctioned way in. .well-known needed two segments, which the single-segment rule refuses. allowed by NAME rather than by pattern, and the reason is in the same namespace: acme-challenge is how this machine gets its TLS certificates, so a plugin owning it could take the domain. caldav and carddav are on the list; nothing else is, for us as much as for anyone. the OPTIONS carve-out moved with it. hono's cors() answers every OPTIONS as a preflight and never calls the route beneath — which to a DAV client is not a preflight but the question "what can you do", answered by a header iOS refuses the account without. that was a literal list of four DAV paths; it asks the claim registry now, so it follows whoever owns the route rather than naming it here. verified: server boots (totality passes), /.well-known/caldav still 301s and /dav/ still asks for basic auth. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
6b5c87da10 |
a plugin can be more than one screen
a plugin was one screen because the two things it declares — a mount prefix and a frontend — happened to be the same string. caldav is where that stops holding: one sidecar, one permission, one install, and two screens that have nothing to do with each other on screen. calendar and contacts are not sections of one page, and forcing them into one would be a worse product than the core screens they replace. nothing else moved. all eighteen plugins with a frontend use flat web/panels.ts, which is now "one screen, at the mount prefix, labelled by the manifest" — the same file, the same route, the same persisted layout key. the nested form is opt-in and caldav is its only user. the split of who declares what follows the rule this file already had. the DIRECTORY declares that a screen exists (web/<segment>/panels.ts); the MANIFEST says what to call it and which glyph to draw, because a directory listing cannot say either and those are a human's choice. the two are cross-checked and a mismatch is refused rather than reconciled: a segment with no directory renders nothing, a directory with no entry has no tile and no label, and both are the quiet kind of broken. the part that needed the most care is that a segment is a URL the plugin CHOSE — /calendar, not /caldav/calendar. mountPrefix is what makes it impossible for a plugin to shadow a core route, and a chosen segment steps straight out of it. so this is first-party only, refused at discovery, and tested: without that check this field is a way for any installed plugin to take over /files. screenId, not appName, is now the layout key and the panel allow-list's owner. two screens sharing one would persist over each other's sizes and each would accept the other's panels. existing plugins get screenId === appName, so no stored layout is orphaned — verified by regenerating. |
||
|
|
c427cb5b61 |
plugins: a backend plugin can have settings
A plugin with no web/ has no route, no dock tile and no panel — so it had nowhere to put a setting. transcribe shipped exactly like that: installed, running, and configurable only by an HTTP call somebody had to already know about. The plugin DECLARES its fields and the platform renders them, on the plugin detail page — the one screen that exists for every plugin whether or not it has a frontend. A component would have survived settings too complicated to describe, at the cost of every backend plugin carrying a frontend to draw one text field, each looking slightly different. The platform stores nothing. It is a form over the plugin's own /config: GET to populate, PUT to save. So a plugin that validates before saving still does, and one that keeps a credential keeps it where it already kept it. A `secret` field is write-only across the boundary — never read back, rendered as a password, and left out of the PUT when untouched so an untyped box is not read as "clear it". The plugin reports whether it HAS one, never the value. Eight strings across all six locales, because the key type refuses anything else — which is the localisation work from earlier tonight doing its job. 1426 pass, tsgo clean, docs:check green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
a10757ba7c |
marketplace: transcribe in the catalogue
Without an entry the plugin is invisible to every server but the one it was built on — the catalogue is what a remote host reads, and the bundled constant is only the fallback when marketplace.officer.dev cannot be reached. Declares no osDependencies, matching the plugin's own manifest: it reaches whisper.cpp with fetch and shells out to nothing. An entry claiming ffmpeg would make an install demand a binary the code never calls. Synced. 19 in the catalogue, and the repository answers an anonymous https clone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
2a0f688e10 |
plugins: installing one grants it to every role
The default was deny-everything: a newly installed plugin reached nobody, and every grant had to be added by hand, per role, on a different screen, by the owner. That made sense while the permission model was being built and there were no members. It stopped making sense once installing a plugin became a deliberate administrative act — somebody who installs a thing has already decided the server should have it, and a second decision elsewhere has one reliable outcome: it gets forgotten and the plugin looks broken. So install grants, the owner revokes. uninstallPlugin already removed those grants, so the pair is symmetric. ROWS, NOT A RULE CHANGE. authorize.ts still denies on absence. The other way to get this behaviour is to make absence mean allow, and that is the version that bites in a year: every capability with no row for any reason — a failed write, a half-finished install, a bug — becomes permitted and nothing looks wrong. The blast radius here is only ever a plugin's own keys; core, execution, admin and confined are unreachable from it. Super Admin is skipped because ck_role_permissions_not_owner refuses it outright and would fail the whole insert. The owner bypasses grants anyway. Also fixes something older that would have made this half-true. A role created through /api/roles began with NOTHING — DEFAULT_ROLE_PERMISSIONS was only ever applied in bootstrap, so a new role could not reach chat, files or terminal while Member could, and nothing said why. A new role now starts where Member does, plus every installed plugin. Leaving it would mean the policy held for roles that existed when a plugin was installed and not for any created after, which reads as a bug in whichever half you meet first. Verified against the live database with a throwaway role and a throwaway plugin: the new role seeded with the confined baseline plus every installed plugin, the grant reached all four roles and never Super Admin, and cleanup left the table exactly as it was. 1417 pass. NOT done: existing installs. browser, email and offscale are installed here and granted to nobody, because they predate this. A backfill is one query and is the owner's call. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
8770f98dcd |
capabilities: read a plugin's where they live, stop copying them in
Four publishers copied a plugin's affordances, notifications and skills into capabilities/ at install. That directory is meant to be forked — core's work plus the owner's — so a fork accumulated files it did not author with nothing on disk saying which, and there was no good answer: commit them and carry another plugin's work, leave them and git status stays dirty. Copying was also wrong in two ways unrelated to git. A copy goes stale, because plugins:update pulls the plugin's directory and the copy keeps describing the old version. And a copy survived plugins:disable, so a disabled plugin's skill stayed in the agent's index — off, and still being announced. Now the loaders take a list of roots: the owner's store, plus each installed AND enabled plugin's own directory. capability-sources.ts resolves that list. No namespace is needed to keep them apart, because separate directories do it structurally rather than by convention. The owner's own win a skill name collision — their root is first and the merge keeps the first of each id — so a plugin cannot take over a name already in use. Affordances are deliberately NOT de-duplicated: several can be true at once. Notifications needed more than a root list, and it would have failed silently. Their codes were derived from the COPY'S PATH, <publisher>/<appName>/file.yaml. Read in place the path is just notifications/catalogue.yaml and carries no identity, so every plugin code would have vanished with nothing raised. loadPluginEntries takes the identity from the manifest instead, which is better provenance anyway — publisher and appName are validated at discovery, where a path was only ever a directory somebody chose. A yaml key still cannot claim another namespace, and that is pinned by test. createSession is async now, only because resolving enabled plugins reads the database. Once per session; its single caller already awaited. Deletes all four publishers including api/tasks/publish.ts from earlier today, superseded before it shipped. app-store/assets stays — images served over HTTP, where the copy is what makes them reachable. NOT done: tasks. That store is read-write, so reads must span sources while writes stay in the owner's directory, and a plugin's task has to be read-only in the UI. Next. Two things the design predicted as orphans turned out not to be, and both were checked before deleting rather than after. capabilities/extensions/tool-loader is written by the platform's own bootstrap every boot, not published by a plugin. And skills/email-db has no source to fall back on — the email plugin ships no skills directory, so that file exists in exactly one place and was hand-written. Neither was touched. 1422 pass, tsgo clean, officer restarts and serves. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
99149443b7 |
plugins: a plugin can ship a task
Assets, affordances, notification catalogues and skills each had a publisher.
Tasks did not, which is the one that matters for reaching the FILE BROWSER'S
CONTEXT MENU: a task carrying `{ type: 'file', extensions: [...] }` already
appears on a right-click with no platform change, because matchesTrigger in
useTasks.ts does that work. What was missing was only delivery — the capability
had to be installed by hand beside the plugin and removed by hand after.
Flat rather than namespaced by publisher, matching skills. A task's directory
name is its identity everywhere: the taskDirName in a job row, the path a script
runner materialises, the label in a run history. Wrapping that in a publisher
would put one in all of those for a collision nobody has had, and a task claims
no authority — it is a menu entry behind a permission that already exists. That
trade is NOT available to affordances, which stay namespaced, because one that
could claim `officer/core:` would be claiming the platform's voice.
The test that earns its keep is the uninstall one. `tasks/` is the owner's own
directory and nothing on disk marks which entries came from a plugin, so the
scope of that delete is the only thing standing between an uninstall and
somebody's hand-written work. Proven rather than reasoned about.
Written for the Whisper extraction, where the transcribe entry has to arrive
with the plugin instead of being seeded separately.
1429 pass, tsgo clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
|
||
|
|
4ca4becaa8 |
plugins: a dependency can declare the architecture its package needs
Minesweeper could not be installed by the plugin flow at all. Its game is an i386 PE, so it needs wine32:i386 — and that package does not exist on a 64-bit host until dpkg has been told the architecture does. No package name can say that, so the step fell outside the manifest entirely and had to be run by hand, which meant an install that reported success and left a game dying with `Bad EXE format` several steps from anything mentioning architectures. Two fields, both declarative: `multiarch: 'i386'` on an osDependency. The platform runs `dpkg --add-architecture` and `apt-get update` before installing, apt hosts only, skipped when already enabled. It is a closed enum rather than a command because it runs as root: a manifest that could supply the string could run anything as root on every machine that installs the plugin, which is a much larger grant than "may install wine". Same principle as a plugin websocket naming a sidecar rather than a port. `packages.apt` may now be a LIST. One binary really can need more than one package — `wine` provides the launcher on PATH and `wine32:i386` the 32-bit loader, and either alone gives a wine that cannot run what the plugin came for. Every element goes through the same regex and reaches apt as separate argv, so a list is no more powerful than the names in it. The regex also now accepts an architecture suffix from the same closed set. Enabling an architecture is system-wide and permanent — nothing here ever disables one, including plugins:delete, because by then something else may depend on it. So it is reported before the owner consents rather than mentioned in a log afterwards, and only for dependencies actually being installed: a binary already present had its architecture enabled at some point in the past. multiarch.test.ts pins the enum against shell-shaped input, the suffix against an unknown arch, and the report against re-announcing a change that is not happening. 1409 pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
6e092c32ea |
marketplace: minesweeper in the catalogue
Without an entry the plugin is invisible to every server but this one — the catalogue is what a remote host reads, and the bundled constant is only the fallback when marketplace.officer.dev cannot be reached. Its git remote was the tailnet address, which marketplace-sync would have published verbatim: a clone URL that works on this network and nowhere else, failing at authentication in a way that reads as a broken plugin rather than a wrong catalogue. Repointed at gitea.officer.dev, which is the rule the sync script's own comment already spells out. Synced. 18/18, and the entry is live with the right source, permissions and six osDependencies; the repo answers an anonymous https clone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
fbdfd3fb89 |
minesweeper leaves the platform; plugin sockets relay ws-to-ws
Minesweeper is now plugins/minesweeper (gitea.officer.dev/plugins/minesweeper). Removes /api/minesweeper, the Dashboard screen, the officerdev panel, the permission registry entry, the App.tsx route and the hand-written websocket provider in server.tsx. Registry entries: 22 -> 21, execution 4 -> 3. The relay changed transport, and that is the part worth reading. A sidecar announces exactly one port. That single announcement is the whole provenance argument in target.ts — the platform watched the port arrive at registration, which is the only thing separating it from a number a manifest asserted. But a raw-TCP relay requires that port to BE a raw TCP listener, so the sidecar cannot also serve HTTP on it, and the plugin's ordinary routes have to fall back to the typed command vocabulary over the registration socket. That is the mechanism the fleet has spent months retiring, and Minesweeper would have been the first thing to go back to it. Relaying websocket-to-websocket dissolves the conflict. The sidecar serves HTTP and /ws on the one port it announced, exactly as the pty sidecar already does; createSidecarProxy handles the HTTP half; and the conversion from frames to whatever the plugin actually speaks happens inside the plugin, which is the only process that knows. The relay is still ignorant of the payload — the ignorance sits one layer up. Also: the caller's JWT is no longer forwarded upstream. A sidecar has no use for it and forwarding one puts a bearer token into whatever that process logs. X-Officer-User carries the only part it needs. (The terminal relay still forwards its token to the pty sidecar; not touched here.) Verified: bunx tsgo clean, bun test 1403 pass / 0 fail, bun run docs:check green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
f2f3ca12ba |
a plugin websocket targets a sidecar, never a port a manifest picked
The fixed-port form shipped this morning and lasted about an hour. The owner asked
whether declaring a port was safe, and it is not: nothing stopped a manifest naming
a port it does not own, so `websocket: { port: 5432 }` plus a grant would have been
a raw TCP relay onto Postgres — authenticated by the platform, available to anyone
that plugin was granted to. Port 22 and officer's own port are the same sentence.
The relay checked that the HOST was loopback and I mistook that for having checked
anything about the port. They are different questions and only the second one
matters: loopback is exactly where everything worth reaching already is.
So the only form is `{ sidecar }`. A sidecar's port is trustworthy for a reason a
declaration can never be — the platform watched it arrive at registration, so the
association between a port and a plugin is observed rather than asserted. The same
distinction that decides the delegation problem in todo/agent-coordination: a claim
is not evidence, whoever is making it.
The cost is that a plugin wanting a socket needs a sidecar. That is not ceremony,
it is the thing that makes the port mean anything. It was added as a shortcut for
Minesweeper, whose x11vnc listens on a known port — which is precisely the case
that looked harmless and generalised into a hole.
Nothing had used it. Minesweeper is still whole in the platform and its extraction
had not started.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
|
||
|
|
eba0e86758 |
a plugin can serve a websocket
Bun's route table is fixed at serve() and WS_ROUTES is read once before it, so a plugin installed afterwards could never add a socket route. That was a hard ceiling, not a preference: /minesweeper had to live in the platform and plugins/desktop could not stream a display at all. The way out was already in the tree and had one consumer. dev-proxy serves a socket with no WS_ROUTES entry, through a WILDCARD route whose handler calls server.upgrade itself. Nothing has to become dynamic, because a plugin name is a path segment rather than a route — one wildcard declared at boot serves every plugin that will ever be installed. Authorisation is the PLUGIN's own capability, asked as if the socket were a GET against its HTTP prefix. That is the safety argument: there is no second permission to grant, forget, or drift, so granting a plugin grants its socket and revoking it closes it. It is also why plugin-ws is exempt from the boot totality check rather than carrying a registry entry — one entry covering every plugin's socket would mean granting one plugin granted them all. A target is a TCP address and never a URL, which keeps the relay ignorant of what it carries. That ignorance is the only reason one relay can serve an RFB display and a line-oriented sidecar. Loopback only and not configurable: a plugin naming an arbitrary host would be a request-forgery primitive handed to every plugin author. sidecarPort() is new because createSidecarProxy learned announced ports per-proxy and that state was unreachable from anywhere else — a second subscription would have been a second thing to keep in step. Verified: boots past the totality gate, and both an existing and a non-existent plugin refuse 401 before revealing which. NOT yet verified: that it relays a byte. Minesweeper is about to be the first caller, which is what proves it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
d043e6650a |
name a published extension for itself, not "extension.zip"
extension.zip says nothing once it is in a Downloads folder — not what it is, not which server it came from, and not which of two it is. The name comes from the extension's own manifest.json, which already carries what a browser will show: "Officer Browser Relay" becomes officer-browser-relay.zip. Generic, since extensions are meant to be a marketplace of them: any plugin shipping extension/ gets a file named after its own manifest, falling back to <appName>-extension.zip when that cannot be read — still better than extension.zip, and it never throws, because a broken extension manifest should fail the install for its own reason rather than because a file could not be named. Uninstall removes both names. An install from earlier today published extension.zip, and leaving that behind would serve a stale extension for a plugin that is gone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
cfb2f98ccb |
plugins can ship a browser extension, published unauthenticated
The browser relay is the first extension, not the last, so `extension/` is now a directory any plugin may ship — like `assets/` — and the installer zips it to public/plugins/<name>/extension.zip, served by the existing /plugins/* wildcard with no auth. Unauthenticated deliberately. An `<a download>` is a plain navigation with no Bearer header, so an authenticated URL makes Chrome say "Try to sign in to the site. Then, download again" — advice that cannot work, because signing in is not what is missing. Nothing in an extension is secret; the pairing token is, it is entered afterwards, and it is not in the archive. Built at install rather than committed. The file it replaces was hand-built and had drifted 58 lines behind its source, serving an extension missing the multi-session recovery path. zip.ts writes stored entries with fixed timestamps — no host `zip` binary, and identical bytes for an unchanged directory. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
e44c0c7665 |
the browser relay leaves, and /scrape stops pretending to be it
plugins/browser now owns the extension, the relay, the CDP bridge, the pairing routes and the two panels. All of it was on disk and switched off since 2026-08-13 — nothing was recovered from history, and nothing regressed, because none of it had been running. What the platform keeps is /scrape, and the two were never one feature split in half. Scrape launches a headless chromium through playwright as the OWNER and knows nobody; the plugin attaches to the browser a person is already signed into, scoped per pairing. The permission is renamed "Scraping" so the registry stops describing one thing in the other's words, and it drops the /browser frontend route it could no longer serve. Two dead screens went with it. BrowserScreen and DesktopScreen were officer-web screens rendering panels through appType placeholders — a plugin supplies its own route and panels through Plugins.gen.tsx, so both were redundant the moment their plugins existed. DesktopScreen was a leftover from this morning's extraction that typechecked cleanly while rendering a panel the AppRegistry no longer had: dead at runtime, invisible to the compiler. The long comment in server.tsx explaining BROWSER_RELAY_PORT goes too, replaced by a pointer to where it lives now. It was written for whoever did this extraction, and this is that. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
0a1f1ce6df |
publish desktop, and stop counting node_modules as a plugin
The catalogue row and the bundled offline fallback, so the plugin installs from the marketplace like every other. Listed honestly: its permission is owner-only at every path, and its websocket is not mounted yet — the README and BLOCKERS.md say so rather than letting an install discover it. marketplace-sync globs plugins/ itself rather than going through discoverPlugins, so the node_modules skip added there did not cover it. Same fix, second place: it was reporting 15/16 and 16/17. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
b1a2d67b76 |
plugins: node_modules is not a broken plugin
The shared install target for plugin npm packages lives in plugins/ because bun resolves node_modules up the tree. Discovery then found it, failed to read a manifest, and reported it as broken on every call — a warning nobody can act on, and a denominator that made the marketplace sync report 15/16. Mine, from the npm-dependencies work. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
4efc2552d0 |
os-deps: a dependency declares who has to be able to run it
The probe asked the owner's login shell where a binary was, and the install reported that as a fact about the machine. For a confined plugin the work happens as the member's own Linux user, in their own home, where the owner's ~/.local/bin is neither on PATH nor reliably reachable. On alpha it worked — because /home/pastilhas happens to be mode 751, world-traversable, so an absolute path resolved. At 750, the more usual default, every member would have lost every binary with nothing reporting it: the install would still have said satisfied, having only ever asked the owner. Nobody chose 751, and that is the part worth fixing. `scope: 'owner' | 'shared'` on an osDependency, defaulting to owner, an unrecognised value being a manifest error rather than a silent fall back to the permissive one. Shared changes three things. It is searched for on system directories only — asking the owner's shell would find the copy in the owner's home and call it satisfied, the exact false pass this exists to stop. Reachability is checked as world-executable AND every parent traversable to the root, because a binary at 755 inside a home at 700 is unreachable and stat on the file alone says it is fine. And a release binary goes to /usr/local/bin through sudo -n install, staged in the owner's space first so the download itself never runs as root. An unreachable shared dependency gets its own state and refuses the install ahead of every other check, because no package manager fixes it: the binary is installed and works, and what is wrong is where it lives — reinstalling would report success and change nothing. Not a security boundary, and docs/plugin-dependency-scope.md says so: anything in /usr/local/bin is runnable by any account with a shell, grant or no grant. That is already true of ffmpeg. The plugin's data is gated; its executable is not. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
87603b800b |
os-deps: ask the owner's shell where a binary is, instead of guessing directories
`Bun.which` reads the PATH this process started with, and under PM2 that is minimal — a release binary fetched during an install was reported missing seconds later by the same install. The fix was prepending ~/.local/bin here. That is guessing at somebody's environment from inside a crippled one, and guessing does not converge. rustup puts cargo in ~/.cargo/bin, nvm puts node elsewhere, and every per-user installer adds a directory nobody listed. The iris plugin duly wrote its own copy of the function with ~/.cargo/bin bolted on, which then disagreed with this one: the manifest probe refused a rustup-only machine that the sidecar could build on perfectly well. There is nothing to guess. The platform runs as the owner, plugins are installed by the owner, and the owner has a shell whose rc files already put all of it on PATH — that is what those files are for. So ask it: `$SHELL -lc 'command -v <binary>'`, with the shell taken from passwd rather than process.env.SHELL, which under PM2 is whatever started the daemon. sessions.mjs already draws that distinction for terminals; this is the same fact applied to dependency probing. A login shell because ~/.cargo/env and friends are sourced from profile files an interactive-only shell may skip. `command -v` because it is a POSIX builtin whatever the shell is. Two-second timeout so an rc file that waits for input cannot hang an install. Bun.which stays as the fallback, with ~/.local/bin prepended there and only there, for an account with no usable shell. Exported as locateBinary so a plugin's sidecar asks the same question the install did, rather than writing a second answer. Verified on this machine: the login shell resolves ~/.local/bin and ~/.opencode/bin, neither of which is on this process's PATH, and finds a binary Bun.which cannot see. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
10feb76880 |
permissions: a plugin can mark part of its surface owner-only
A plugin worth granting to a Member can also hold the credential the rest of it runs on. Jellyfin's /_config stores a server's admin token and drives account provisioning; the rest of the plugin is exactly what a Member should have. The choice was all of it or none of it. `ownerOnly` on a plugin permission takes sub-paths relative to the mount prefix. The permission stays fully grantable — this is not the ownerOnly PERMISSION that manifest.ts refuses, and that refusal still stands: a permission nobody can delegate is a contradiction. This says a few paths administer the plugin rather than use it. Declared by the plugin, enforced by the platform. A plugin could check this itself and the first one would; the tenth would add a route and forget, with no boot failure and no warning — the shape assertPermissionTotality exists to prevent. In the gate it is enforced once, on the door they all pass through, and a route added later by someone who never read the manifest is covered by default. Checked before selfService and before kind. A path that is somehow both self-service and owner-only is a contradiction, and the safe reading has to win. A write grant does not unlock it either — level is not the question this answers. Also forwards X-Officer-Owner and X-Officer-Level to a sidecar whose proxy names its permission key, for the finer decisions the platform cannot make for it: whose rows a read returns, which fields a level may see. Facts, never the role name — a plugin branching on "Super Admin" would give every plugin repository an opinion about the platform's role vocabulary, and renaming a role would change authorization in code the platform does not own. These are safe to trust only because the proxy copies a three-item allowlist off the inbound request; that is now written where it would be changed. The owner's level is reported as write rather than the absent 'none': they hold everything implicitly and appear in no grants map, so the raw lookup inverts the meaning for the one account that can do anything. No plugin declares one yet. Jellyfin cannot until connections stop being per-user — locking /_config today leaves a Member with a plugin that only shows a connect form they may not submit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
da30d1a69e |
plugins: a reinstalled plugin was still being read from the deleted one
Delete jellyfin, install it again at a newer commit, connect a server — and no accounts were created,
no mapping rows written, and no error anywhere. Every step reported success.
The new checkout writes manifest.ts at exactly the path the old one occupied, and `await import(path)`
returns the module it already has: ESM caches by resolved specifier, and the specifier did not change.
So the platform kept obeying the manifest of a plugin that was no longer on disk. That manifest
predates `userProvisioning`, so the plugin was judged not entitled to the account list, the sidecar
was sent nothing, and provisioning skipped with "the platform has not sent the account list yet" —
a report that goes into the response body nobody reads.
It began working four minutes later for a reason that had nothing to do with the plugin: officer was
restarted for an unrelated deploy, which emptied the module registry.
`importFresh` keys the import on the file's mtime, so a file that changed is evaluated again and one
that did not is still a cache hit — `discoverPlugins` runs on every sidecar registration, every mount
and every plugins page load, so a Date.now() key would leak a module instance each time.
Used for the manifest, the api router and the db schema. For manifests it is complete: every manifest
imports one thing, `import type { PluginManifest }`, which is erased before it runs, so they are leaf
modules. For the other two it narrows the window rather than closing it — only the named file is
re-read, and its own static imports come back from the cache. A restart is still the only thing that
guarantees a plugin's whole module graph is the one on disk, and that is written down in the file
rather than left to be rediscovered.
The test pins the failure and not the mechanism: one case proves a plain import keeps serving a
manifest that is no longer on disk, the next proves importFresh sees the replacement, and a third
proves an unchanged file is evaluated once. mtimes are set explicitly, because two writes inside one
millisecond would otherwise decide the result.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
16dd67a3ca |
plugins: enabling pulls first, so a plugin off for a week comes back current
A disabled plugin keeps its directory, and the directory is what the next start runs. The longer it stays off the more certainly it is stale, and enabling would quietly run whatever was current on the day it was switched off — with every step reporting success, which is exactly how the jellyfin install on alpha re-applied a two-day-old checkout. Deleting no longer has that problem, since it takes the directory with it and installing is always a fresh clone. That left disable/enable as the one remaining way to run old code without being told. The pull is best-effort and the enable is not. `updatePlugin` returns ok:false when a pull is refused, and it is refused for reasons that say nothing about whether the plugin can run: a dirty working tree — every plugin directory on this machine is one — an unreachable forge, or a directory copied in rather than cloned. Enable is the reversible half of a pair with disable, and a switch that will not turn back on because a network is down is worse than one running last week's code. So the refresh reports which of those happened and the enable proceeds regardless; a re-apply that fails falls back to the code already on disk, which was running before it was disabled. Tested by the refusal path rather than the happy one: a directory that is not a checkout comes back having reported "not refreshed", and the error that surfaces is about not being installed rather than about git — which is only possible if the refusal was stepped past instead of returned. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
287cec39d0 |
plugins: enable, disable, delete — and uninstall is gone
Three states all read as "stopped": disabled, uninstalled, and uninstalled-with-the-directory-still- there. Nothing on screen distinguished them, and the one that mattered was invisible — an uninstalled plugin kept its directory, which silently decided what the next install would run. That is how a jellyfin install on alpha re-applied a checkout from two days earlier while reporting success at every step. So uninstall stops being a verb. What it did survives as the first half of deleting: it stops the sidecar, removes the install row and the PM2 entry, unpublishes the copies, and revokes grants naming permissions that are about to stop existing. Delete is offered only on a plugin that is not running — disable is one reversible click, so a live plugin is always two deliberate steps from gone. It opens a confirmation carrying a checkbox, "also delete all its data", unticked and never inherited from last time. Unticked, the directory goes and the rows stay, and they appear in the orphan list further down the same screen. Ticked, the tables go with it, which is the only way to a machine that has never had the plugin. The checkbox picks between two ROUTES, not a body flag on one: a flag can be added to a request by anything that already knows how to call the safe verb, and the difference here is somebody's rows. Grants are revoked either way, which is load-bearing until the permissions work tomorrow: a grant naming a key the platform no longer recognises made the whole permissions screen unsaveable, because the page submits every grant it loaded. Also corrected two rationales that the change made false — the runbook's "uninstall never destroys data" verification table, and npm-deps.ts explaining directory survival in terms of uninstall. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
a6e61d0c26 |
user provisioning: the platform pushes the account list to plugins that declare it
a plugin that creates accounts in an upstream service — jellyfin first, immich next — needs to know who the platform's users are. it now gets told. DECLARED IN THE MANIFEST, not asserted at registration. `name` and `handles` arrive on an unauthenticated socket and are believed, so a capability claimed there would be a plugin granting itself one. the manifest was reviewed when the owner installed it, and sits beside `permissions` where "this plugin will be told who your users are" belongs — on the install screen, not discovered later. PUSHED, not requested. a sidecar has no authenticated way to ask, so a request could only be answered, never authorised. a push inverts that: the platform decides who gets it, and a caller registering under a name with no such manifest gets nothing. `listProvisionableUsers` is a hand-written projection — id, email, username, name, role — and NOT `select *`. `users` holds `password` and `os_user`, and `getUsers()` returns both, so a plugin reaching for the obvious function would get every account's password hash. naming the columns means adding a secret to the table cannot silently add it to what plugins receive. pinned by a test that asserts the key set, not just the absence of today's secrets. the owner is excluded. they bypass every permission check and already have whatever upstream account they want; provisioning them is at best a no-op and at worst a second admin nobody asked for. sent after the ack rather than inside it, so registration never waits on a database query — a slow postgres would otherwise stall every sidecar's startup, and none of them need the list to start. and re-sent on every account create and delete, because the registration copy is a snapshot: a plugin still holding a deleted user would keep an upstream login alive for someone the platform has removed. read once per broadcast, so two plugins cannot be told different things about the same moment. |
||
|
|
c336042b12 |
remove a dangling export, and write down the lockfile question
`pluginPackageJsonExists` was exported for an uninstall hook that was never wired. an export with no caller is an unfinished thought pretending to be a feature, so it is gone — and the behaviour it would have served is right anyway: uninstall KEEPS the plugin directory, so its packages are still declared and still wanted. only "delete and all its data" removes the directory, after which the next install regenerates the merged file without it. the packages linger until then and go on their own. and the thing the owner asked to come back to: CLAUDE.md's frozen-lockfile rule reads as universal, and npmDependencies does not follow it. ranges resolving per machine is what lets two plugins share one sharp, and it was chosen knowingly — but the codebase now holds two postures on one risk with only one of them written down. recorded in TODO.md with the three specific things worth settling rather than left as a feeling that something is inconsistent. |
||
|
|
21bedf1418 |
npmDependencies in the manifest, installed once for every plugin
replaces the per-plugin package.json from earlier tonight. the owner's call, and the reason is one i had not weighed: sharp is ~50 MB of platform binaries and the obvious dependency for anything touching images, so a second and third plugin wanting it would have meant a second and third copy with no version they could converge on. so every plugin's `npmDependencies` merge into ONE generated plugins/package.json, installed into plugins/node_modules/. bun resolves node_modules up the tree, so that directory serves every plugin and none of the platform's own code. ranges intersect rather than alternate. two plugins asking for ^0.33.5 and ^0.33.9 produce "^0.33.5 ^0.33.9", which npm and bun read as AND — the resolved version must satisfy both. `||` would have meant "either", which is the silent version of this failure: one plugin quietly getting a version the other cannot import. incompatible requirements now fail the install with bun's own message, and both askers are named in the steps either way. generated from the plugins ON DISK, like generatePluginSchemas, and never edited — so a package cannot appear there without a plugin having declared it. what this gives up, knowingly: a committed lockfile. ranges are the whole point and a range resolves per machine, so plugin packages are resolved at install time like osDependencies are, and unlike the platform's own. CLAUDE.md is emphatic about frozen lockfiles and 2026-08-04, so it is named in the manifest type rather than left to be discovered. the generated plugins/package.json and lockfile are gitignored — /plugins/*/ only ignores directories, so files at that level needed naming explicitly. |
||
|
|
3450f5eb57 |
warn when a plugin's packages would block its own updates
update is a git pull and then the ordinary install path, so the npm step is picked up for free — a plugin that adds a dependency in a later version gets it installed on update, with no second implementation to drift. but pullPlugin refuses on ANY uncommitted change, and node_modules/ that nothing ignores is one. so a plugin shipping package.json without ignoring it installs perfectly once and can then never be updated again: the button reports "N uncommitted changes" forever, about a directory the platform itself created, and nobody seeing that message has a reason to connect it to a package.json from three versions ago. every plugin that exists today ignores node_modules, which is luck rather than a rule — this makes it a checked one. asked with `git check-ignore` rather than by reading .gitignore: the rule may be nested, global, or in .git/info/exclude, and all three count. warned, not fixed. writing into the plugin's own .gitignore would be the platform editing a repository it does not own, and would itself dirty the checkout — which is the exact thing being avoided. |
||
|
|
7e654d75bc |
plugins can declare their own npm packages
osDependencies covered binaries on PATH — ffmpeg, radicale, tea — and there was nothing for a javascript package. so a plugin needing one had to add it to the PLATFORM's package.json, which is what atelier did with sharp, carefully, with the frozen-lockfile dance documented in BLOCKERS.md. it still broke on the first machine that was not this one. alpha pulled the package.json, nobody ran bun install, and officer-atelier crash-looped on "Cannot find package 'sharp'" — after the install flow had reported success. three things were wrong with that shape. the platform carried a dependency only one plugin needs, and uninstalling the plugin would not have removed it. installing a plugin could not install it, so "installed" and "can start" were different facts with nothing checking the second. and it forced the platform's lockfile open, which is precisely the signal frozenLockfile exists to keep rare. now: a plugin ships its own package.json and lockfile, committed in its own repository, and the install flow runs `bun install --frozen-lockfile` in the plugin's directory. bun resolves node_modules up the tree, so plugins/<name>/node_modules serves that plugin and nothing else, and uninstall takes the packages with the directory. the presence of the file is the declaration — no manifest field, same reason sidecar/index.ts has none: a second place to say it is a second place to be wrong. verified by RESOLVING, not by the exit code. sharp resolves to a platform-specific binary package, so a lockfile that installs cleanly on one machine can leave nothing importable on another — which is the exact failure being fixed, and checking the exit code would have reproduced it one layer up. sharp is off the platform's package.json. the lockfile diff is that removal plus @img/sharp-libvips-* re-resolving 1.0.x → 1.2.4, because the platform no longer pins sharp; nothing else moved, checked line by line. the platform still reaches sharp transitively through next, which is where it came from. |
||
|
|
329c47c499 |
atelier in the marketplace catalogue
the repo exists now — gitea.officer.dev/plugins/atelier, public, master, in the plugins org like every other one — so the bundled catalogue can name it. BUNDLED is the offline floor under marketplace.officer.dev, not a duplicate of it: a machine that cannot reach the marketplace still sees the plugin. the sync picks it up from gitea on its own, verified — 14 plugins, atelier among them. `has` says all four, which is true of it: the proxy router, atelier_* tables in officer_db, the generation sidecar, and three panels. |
||
|
|
49f7761f53 |
skills: publish the index, leave the body on disk
the inverse of an affordance, and deliberately so: affordance body always compiled in, description never sent skill description always compiled in, body read on demand an affordance is a fact about the surface — short, and every conversation needs it. a skill is reference material that is dead weight in a conversation about something else. so what reaches the prompt is the index: name, when to reach for it, and the path. measured on the first one: 608 characters in the prompt against 6,070 bytes left on disk. no protocol. progressive disclosure is normally a harness feature — claude code discovers .claude/skills, opencode has its own conventions, the next one will have a third — and pointing at a file needs none of that, because every harness worth running can already read a file. the cost is that the agent has to CHOOSE to open it, which is what the description is for and why a vague one makes a skill useless rather than merely verbose. the first skill is the email database, moved out of tools/email-db/TOOL.md where NOTHING has been reading it: the loader parses frontmatter and discards the body, so that schema and its eight queries have never reached any claude code session. written for pi, inert since pi left. it carries two things the schema alone gets wrong, both measured against the real 19,097-message database rather than copied from the old doc: `read` is populated on 93 rows, so the documented unread query returns essentially the whole mailbox; and `labels` is a comma-separated string with real values like `updates,inbox`, so `labels = 'inbox'` misses most of the inbox. plugins can ship skills, published and unpublished on install like affordances and notification catalogues. flat namespace unlike those two — a skill's directory name is the path an agent reads, and `officerdev/gitea/SKILL.md` in a prompt is noise. a skill has no authority to usurp, so nothing needs protecting. wired to claude code. opencode still gets NOTHING — its runner sends no system prompt at all, and session create takes only a directory. that needs its own channel and a guess would be worse than the gap. |
||
|
|
55d6eba511 |
notification catalogue: GET /api/notify/mapping
the piece that makes a new notification kind a server change rather than an app store release. a push carries a code and integers; this is what the code means, synced to the device instead of compiled into it. deliberately the same shape as affordances rather than a second mechanism: a `notifications/` directory of yaml in the plugin repository, copied to capabilities/notifications/<publisher>/<appName>/ on install, removed on uninstall. publish.ts mirrors affordances/publish.ts closely enough to diff. the namespace is DERIVED from where the file lands, never from what it declares — same rule as mountPrefix. a plugin writing `officer/core:test` as a key gets `<publisher>/<plugin>:officer/core:test` and the real core entry is untouched. that is the security property of the whole scheme and it is pinned by test. version is a content hash, not a counter. a counter needs somewhere to live and is then wrong after a restore, a rollback, or two servers built from the same install; a hash needs no storage and is right in all three. so the client compares mv !== version, and both docs now say so — i had written "higher". core entries ship in code rather than on disk, because capabilities/ is installed rather than committed and `test` has to exist on a machine that has installed nothing — it is what first light is proved with. read from disk per request, not memoised: installing a plugin has to change the answer without a restart, or it presents as "the new plugin's notifications don't work", which is the hardest kind of bug to place. verified live: 401 through the platform, plugin catalogue appearing and disappearing with no restart, version returning to its prior value when the plugin is removed. 10 catalogue tests, 100% coverage of catalogue.ts. |
||
|
|
4b22f52c7e |
update a plugin whose local branch no longer exists on the remote
The Update button failed with "couldn't find remote ref main" on every checkout made before the plugin repositories were standardised on `master`. It pulled `origin <local branch name>`, and the local branch and the remote's default had stopped agreeing — so the button could never work again on exactly the installs that most needed it. Now `git fetch origin HEAD` and a fast-forward: HEAD resolves to whatever the remote's default is, so the local name stops mattering, and the merge applies it to whichever branch the checkout happens to be on. Fourth time today that naming a branch was the bug — the marketplace icon URLs, the capabilities sync, the setup script, and now this. The rule that keeps holding: fetch HEAD, never a name. Verified by reproducing it — renamed a real checkout to `main`, pulled against a remote that has only `master`, and it succeeded. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
0fd89082fe |
an uninstalled plugin's grants stop breaking the permissions screen
Uninstalling music left its grants in role_permissions. The keys stop existing when the plugin unmounts, so the settings page — which loads every grant and submits them all back — failed with "Unknown permission 'music'" for a row nobody could see, edit or remove through the UI. Nothing could be saved again without editing the database. Two changes, because either alone leaves half the problem. Uninstall now revokes the plugin's grants, across every role rather than one. A deliberate exception to "uninstall deletes nothing": a grant is the PLATFORM's record of who may reach the plugin, not anything the plugin stored. Reinstalling means granting again, which is the honest behaviour — the alternative is access silently returning. And the write DROPS an unknown key instead of refusing the request, so every server already in this state is unblocked without touching its database. That is what the resolver already does on read, so the write now agrees with it. The cost is that a typo in a hand-made API call is ignored rather than reported — worth less than a settings page that cannot be used. The response returns what was dropped, so it is not silent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
1e03c600b8 |
stop naming a branch when pulling the capabilities repository
The plugin and capabilities repositories were standardised on `master`, and five places said `origin main` — the setup script's sync and the Update-all path. Swapping them to `master` would be the same mistake wearing a new name. `HEAD` resolves to whatever the remote's default is, so the question stops being asked, exactly as it now does for the marketplace icon URLs. Only the LOCAL branch still needs a name. Third time today that naming a branch was the bug: the icon URLs 404'd on four repositories created as `master`, and these would have broken the moment the rest were renamed to match. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
5b486ed019 |
build marketplace icon URLs from HEAD, not from a branch name
Every available plugin drew a broken image on any server reading the remote catalogue. The URLs said /raw/branch/main/, and four plugin repositories were created on `master` — so those 404'd, and the others only worked by accident of which branch somebody happened to use. The comment above the builder already said "a repo whose default branch is `master` gets a 404", marked [open]. It then happened. A warning in a comment is not a guard. Gitea resolves /raw/HEAD/ to whatever the default branch is, so the question stops being asked. Verified against both spellings, and all 12 icons in the live catalogue now answer 200. It did not show on alpha because every plugin there is INSTALLED, and an installed plugin's tile is served locally from /plugins/<name>/ rather than from the catalogue. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
09f2cfa267 |
a plugin can depend on a release binary, and prove it is the right one
osDependencies could only say "this package, from this manager". Gitea's `tea`
is not in any distribution — it is a Go binary from dl.gitea.com — and Debian
has an UNRELATED package of that name: a graphical text editor. So the one
dependency gitea actually wants could not be expressed, and its marketplace
page showed no dependencies at all while music's showed two.
`release: { url, identify }` covers it. {os} and {arch} are filled from the
host in the spelling Go releases use, and the binary is fetched to
~/.local/bin — no root, and nothing a plugin downloaded from the internet
sitting beside what the distribution installed. Written to a temporary name and
moved into place, so a half-downloaded file is never on PATH under the real
one.
`identify` is the part that matters. Bun.which('tea') cannot tell Gitea's CLI
from the text editor, so presence was never the same question as identity —
and reporting the wrong program as satisfied is worse than reporting it
missing, because the install succeeds and every call afterwards fails
strangely. A binary that fails to identify is now `present: false, imposter:
true`.
Found while testing: Bun.which() with no options reads the PATH the PROCESS
started with, which under PM2 frequently excludes ~/.local/bin entirely. A
release binary fetched during an install would have been reported missing
seconds later by stillMissing, failing an install that had just succeeded. The
search now passes PATH explicitly, with ~/.local/bin first.
Verified against the real binaries on this machine: the genuine tea identifies,
a fake that prints text-editor output is caught as an imposter, and both
dl.gitea.com URLs (linux-amd64, darwin-arm64) answer 200.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
8fe1b1824b |
a plugin can ship affordances, and installing publishes them
A plugin is a new SURFACE arriving at runtime, which is exactly the case hand-written prose could never cover: install gitea and no agent learns that `tea` is authenticated, or that plain `git` is the better tool for what git already does. The plugin knows; nothing carried it across. An `affordances/` directory in the plugin repository is copied to capabilities/affordances/<publisher>/<plugin>/ on install and removed on uninstall — the same shape as assets/ and publishAssets, deliberately, because two mechanisms for "the plugin ships some files" would drift and that one is already understood. Namespaced by publisher because two plugins named `gitea` from different publishers must not overwrite each other, and because an affordance in the tree should say whose it is without being opened. It mirrors mountPrefix. Uninstall removes the PLUGIN's directory, never the publisher's: every first-party plugin publishes under one officerdev/, and taking that level out because one was uninstalled would strip the rest silently. That is what the tests are mostly about — a copy going wrong is obvious, this would not be. Publishing is not fatal to an install. An affordance that failed to copy costs an agent some knowledge; refusing the install over it costs the owner the plugin. Verified against the real gitea plugin: published, the loader saw officerdev/gitea/gitea beside officer/core/image-rendering, uninstall removed it and left core alone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
d49776307d |
gitea, extracted into plugins/gitea — the platform half
Eleventh extraction, and the only one that deleted its UI instead of moving it. Seventeen files under apps/Gitea/ reimplemented a fraction of an interface Gitea already has; the plugin renders the real one in an iframe over a same-origin proxy. Deleting rather than moving removed a platform change nobody now has to build: useGiteaLocation read :owner/:name/:tab/:item from five route patterns where a plugin gets two, so an as-is extraction would have required teaching the plugin system to declare extra route patterns. Nothing inside an iframe needs a route. Nothing working was lost. The router had been commented out of hono.ts since 2026-08-13 with api: [], and officer-gitea was running nowhere — the screen rendered and every request 404'd, like soulseek, jellyfin and invoices before it. Gitea also stops being a CORE DOCK ITEM. It was hardcoded in CORE_DOCK_ITEMS, the only installable feature that was, on the reasoning that it fronts a remote instance and installs nothing locally. As a plugin its tile arrives from assets/icon.png like every other — which is also why the icon added to public/icons/ this morning was reverted rather than kept. 'gitea' stays in the ServiceName union: the connection lives in core service_connections, and gitea is the reason that table has its "member rows carry only a token and inherit the owner's URL" shape at all. registry.test.ts's grantable-permission test named gitea as "the case this was built for"; a plugin's permissions are registered at install and are never in that compile-time list, so it now names a core one. catalogue.test.ts re-anchored to vnc/notify. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
4b2c896523 |
ship the capabilities repository, and update it from the same button as plugins
capabilities/ is the file-based item store, and most of it belongs to the owner: the six item directories are created by the platform at boot and extensions/tool-loader is written from platform source, so neither is tracked anywhere. What IS tracked is the small set the platform ships — today one affordance, telling an agent that markdown images render and that the USER'S DEVICE fetches the URL, which it cannot discover from the filesystem. That now lives in officerdev/capabilities, public so a fresh install can clone it without credentials. Two ways in. The setup script syncs it during the layout step — non-fatal, and it says what an install without it costs rather than failing: agents go back to guessing, which is what they did before this existed. And "Update all" on the marketplace screen carries it alongside the plugins, because it is the same question — get me what has been published since I installed — and a second different gesture for it is one nobody remembers. Three states in both places, and the third is the normal one on an existing machine: a checkout is pulled, an empty directory is cloned into, and a directory that already holds the platform's generated items gets a repository initialised around them. UNTRACKED files never block a pull, because the owner's own skills and tools living here is the ordinary state — only tracked changes do. `cloned` and `adopted` are reported in the UI where `pulled` is not: they mean this machine had no affordances at all until the button was pressed. Verified against the real checkout: sync reports pulled, and the loader finds officer/core/image-rendering afterwards. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
3ef078210a |
update a plugin, or all of them, from the marketplace
There was no update path. fetchPlugin REFUSES an existing directory — cloning over one is how uncommitted work disappears — so install was a one-way door, and the answer to "my plugin changed" was uninstall and reinstall, which drops its tables on the way past. An update is a git pull and then the ORDINARY install path. installPlugin already skips the clone when the directory is present and re-runs everything else: os dependencies, schema push, assets, the ecosystem entry, the sidecar, the remount and the frontend rebuild. So there is no second implementation of "apply a plugin" here to drift from the first. Two refusals, both about not destroying work. A dirty working tree is refused rather than stashed or force-pulled — every plugin directory on a developer's machine is a live checkout, and this repository's own are edited in place. Non-fast-forward is refused for the same reason: a diverged local commit is somebody's work and merging it automatically is not the platform's decision. The per-plugin button replaces one that was quietly broken: it appeared only when `outdated` and called `install`, which cannot fetch, so it re-applied stale code. Nothing bumps a version today either, so with everything pinned at 1.0.0 that flag never fires and the button never appeared. Now always offered, and it pulls. Update-all is a plain POST rather than the streaming door: twelve interleaved step streams is not a readable log, and the useful answer is the summary. It runs sequentially — each update pushes schema, republishes assets, rewrites the ecosystem file and rebuilds the frontend, and two of those at once is a race over one file each. One failure does not stop the rest, and failures are named with their reason, because "2 failed" sends someone to the terminal. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
cfbec0b757 |
plugins: a splash is part of a plugin, like its icon
assets/splash.png is discovered by presence, published by the same copy that already took the whole assets directory, and carried on DiscoveredPlugin beside icon. The platform draws no splash — a plugin has no launch. It is carried because the plugin repository is where a product's artwork lives now, and the phone app that fronts the same product needs somewhere to read it from that is not a designer's folder. The pair is the point: the icon is a reduction of the splash's world, and keeping them together is what stops them drifting. Publishing no longer gates on the icon alone, which left a plugin shipping only a splash serving nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
bbb16d6ed3 |
bitcoin-wallet, extracted into plugins/bitcoin-wallet — the platform half
Tenth extraction, and the first where the blocker was a permission rather than a path. The wallet was kind: 'admin' — owner only, never grantable — and plugin permissions are always kind: 'app'. mount.ts forces that and the manifest has no field for anything else, deliberately, so a plugin cannot ask to be restricted. Extraction therefore could not preserve admin. Raised before moving any code, because turning "the server owner's bitcoin" into a row in the grants list beside Photos and Music is the kind of change nobody notices until someone ticks it. Made anyway, and the objection inverts: every wallet query is scoped by user_id, taken from X-Officer-User which the platform injects from the session, so granting the permission gives an account its OWN wallet rather than access to anyone else's — including to the owner, whose bypass covers permission checks and not query scoping. kind: 'admin' was single-user thinking. Named bitcoin-wallet, not wallet: appName drives route, API prefix, table prefix and install key together, so the name should say what it is. Tables are bitcoin_wallet_*; the prefix guard normalises the hyphen. Verified free before committing to it — no wallet table exists on any server, and db:push would treat a rename as a drop plus a create. registry.test.ts re-anchored: `wallet` was its canonical kind: 'admin' fixture and its no-selfService fixture. Now user-admin/server-admin and terminal, with a note that a plugin's permissions are always `app` — which is exactly why this one needed a decision rather than a default. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
d6f093532d |
install radicale and register officer-caldav on an existing server's next boot
Calendar and contacts became core in |
||
|
|
f566fda0b2 |
invoices, extracted into plugins/invoices — the platform half
Ninth extraction, third resurrection. Switched off since 2026-08-13 with the permission reduced to api: [], so assertPermissionTotality was satisfied, the server booted clean, and every panel request 404'd. Closes the same data hazard as jellyfin and soulseek: invoiceshelf/schema was commented out of the drizzle barrel. Named `invoices`, not `invoiceshelf`. The appName is THE id — API prefix, frontend route, table prefix, sidecar suffix, install key — and before extraction the API (/api/invoiceshelf) and the screen (/invoices) had drifted apart, which was harmless only while they were separate hardcoded strings. The route is the part a person sees, and the permission key and label were already `invoices`, so the API prefix was the odd one out. Cost three call sites and a table rename to invoices_accounts, which was free only because the table existed on no server — verified before deciding, not after. members.test.ts re-anchored again, this time to caldav and memos: services with no extraction planned. That assertion has now been re-pointed four times by services leaving for plugins/, which is the whole reason installer.test.ts was made literal earlier tonight. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |