773f0e1c8bd6c27f4d8eb97c2cd4a9a096a12b8a
695
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
dd26e4a688 |
example leaves too; plugins/ is documentation now
Done with `tea` as admin: plugins/offscale made public, plugins/example created public, both verified anonymously over https — which is the check that matters, because fetch.ts clones with no credentials and a private plugin simply cannot be installed from the marketplace. plugins/example 8 files, cloned back and diffed identical plugins/offscale now public (was private, so was uninstallable) `.gitignore` is one line now — `/plugins/*/`. The trailing slash is the whole design: it ignores DIRECTORIES, so files at the top of plugins/ stay tracked. The documentation about plugins belongs to the platform; the plugins do not. All three are in the marketplace catalogue, which is what makes this reversible: before this, untracking offscale meant a fresh install could never get it back. Now the store is the way in for all three. CatalogueEntry gains `icon` (a lucide name) alongside `iconUrl`. Both new entries ship glyphs rather than artwork, and without it they drew the generic puzzle piece in the store and their real icon after install — which reads as the icon changing rather than the store not knowing it. EXTRACTING-A-PLUGIN.md pointed at four directories a fresh checkout no longer has. It now gives repository URLs, says up front that this directory holds documentation rather than plugins, and carries a table of where each one went. Checked first that no tracked source statically imports a plugin directory — none does, so a fresh clone still builds. tsgo clean, frontend builds, 787 pass / 7 fail (unchanged). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
29170dd922 |
installing a plugin pins its dock tile, with no reload
Installing Music left the dock unchanged. The tile existed server-side the moment the plugin was enabled, and the only way to see it was Profile → Dock, by hand, after a page refresh. Installing something is already the decision to use it; saying so twice is the bug. Three separate causes, which is why it looked like one stubborn one: 1. `dock_configs.paths` is a COMPLETE ordered list, not a diff — useDock renders the saved list rather than everything available, so anything not named in it is invisible. Nothing in the plugin system had ever written to that table. installPlugin now appends the new route, for the caller (threaded through the route) rather than a guessed owner id. Only for a user who HAS a row. A user without one is left alone deliberately: there is nothing to append to, and writing one would freeze today's defaults into their account permanently. That case is (2). 2. The fallback for a user who never customised was DEFAULT_DOCK_PATHS, which is core-only — so a plugin tile was hidden there too. `defaultDockPaths(plugins)` is now that list plus a tile per installed plugin, and the dock and its settings screen both call it so they cannot disagree about what "default" means. 3. useDock holds ['DOCK'] at staleTime: Infinity and the plugin verbs invalidated only ['plugins'] and ['self-permissions']. So even once the server wrote the path, nothing asked again — that is the part that made a reload necessary. Also fixes a guard I broke reaching for this: typing VERBS as Record<string, VerbFn> widens keyof to `string`, which makes `isVerb` prove nothing and lets an unknown verb index the map. `satisfies` instead, and there is now a check that Verb is still the four-member union. Uninstall deliberately does not remove the path. A stale path with no item behind it is dropped by useDock, and keeping it means disable/enable and reinstall put the tile back where the user had it rather than at the end. tsgo clean, frontend builds. 787 pass / 7 fail — 6 identical to before; the 7th is plugins/music/cliamp, which install pulled into test discovery and which the manifest documents as parked. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
dadfdc26af |
plugins page becomes a marketplace: catalogue, clone-on-install
The /plugins route showed only what was already on disk, so a plugin nobody had copied in by hand did not exist. It now shows a third list — what a remote catalogue offers — and installing one fetches its source. - plugins/marketplace.ts — a stand-in for the remote store, a JSON array in process. One entry: music, pointing at gitea.officer.dev/plugins/music. It is deliberately a module and not a fetch, so the shape of a catalogue entry gets settled before anything depends on a server answering. - plugins/fetch.ts — git clone --depth 1 into plugins/<appName>/, INSIDE the platform repo because bun resolves the workspace links by walking up and a plugin cloned beside it cannot import anything the platform provides (measured, not assumed). https only, no credentials, GIT_TERMINAL_PROMPT=0 so a repo that turned private fails instead of hanging. A failed clone removes the directory: a half-written one reads as a broken plugin rather than an absent one, which is the worse of the two. - install.ts fetches first when the directory is missing, then RE-DISCOVERS rather than assuming what it cloned. Everything after is the existing path, including the frontend rebuild — so a cloned plugin's panels reach the SPA without a restart. - GET /api/plugins returns `available`, catalogue minus what is on disk. Each entry gets the same live PATH probe a local plugin gets, so music's ffmpeg and ffprobe are answered for this machine BEFORE the clone rather than after. - The detail panel is a separate component for a catalogue entry, not a mode of the installed one: there is no sidecar status, no enabled state and no route to open, and what there IS — the source URL — the installed panel has no reason to show. The streaming log is shared, because the marketplace install is the case that needs it most: it clones before it does anything, and a slow clone behind a blank panel looks like a hang. The catalogue's icon is an absolute URL to the store's copy, which is the [open] question the previous commit left: assets only publish at install, so a plugin you have never installed had no artwork to draw. Not done: no update verb, no signature, no private repos, and removePluginSource has no caller — deleting source drops tables on the next push, and that wants a verb that says so. tsgo clean. 767 pass / 6 fail, all 6 identical on HEAD without this change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
9c2d6a97f7 |
a tested migration script for the capabilities→permissions rename
Other machines have to make the same database change, and pasted SQL is the
wrong way to ship it. `scripts/rename-capabilities-to-permissions.ts` renames
the table, its column, both indexes and its three CHECK constraints in one
transaction, and refuses to guess:
neither table nothing to do; db:push will create it
already renamed no-op, prints the grant count
BOTH tables present stops and says a human must decide
old table only migrates, counts before and after, prints every grant
Testing it found a bug that reading it had not. The existence checks were
inside the transaction but ran on the pool rather than on `tx`, so they could
not see the uncommitted rename: `columnExists('role_permissions','capability')`
answered false because that table did not exist yet on that connection, and the
COLUMN rename was silently skipped. The result was a `role_permissions` table
with a `capability` column — half migrated, and only failing on the next query.
That was found by building a scratch database in edge-pertento's exact shape
and running the script against it, rather than by review. Every check now
happens before the transaction, against the old names.
Verified end to end on that scratch: 6 grants migrated intact, a second run
correctly no-ops, and `db:push` afterwards leaves role_permissions and its rows
alone while creating the rest of the schema. Indexes come out as
role_permissions_pkey and uq_role_permissions_role_permission.
Also swept the last all-caps survivors the case-sensitive passes missed:
CORE_CAPABILITIES → CORE_PERMISSIONS, and a react-query key still spelling
['ROLE_CAPABILITIES']. The only CAPABILITIES left in src/ is the wallet's, which
is Lightning and stays.
|
||
|
|
027b10bd6e |
step 4/4: the docs say permissions too, and capability means one thing again
44 files of prose — CLAUDE.md, AGENTS.md, TODO.md, 20 docs, both plugin design
documents, and the comment surface the earlier steps could not reach.
Applied against an explicit keep-list, not swept, because the word turned out to
have SIX meanings in this repository rather than the three the offscale doc
recorded:
permissions renamed (steps 1–2)
$OFFICER_ROOT/capabilities/ KEPT — the item store, and now the only thing
the word means that is ours
sidecar routing keys renamed to `handles` (step 3)
Lightning wallet KEPT — a domain term, and on the wire to the mobile apps
terminfo queries KEPT — XTGETTCAP, in the pty sidecar
InvoiceShelf KEPT — per-resource { write, bulkDelete } flags
The sweep still falsified two things, both caught by checking rather than by
review, and both in prose that discusses more than one meaning at once:
CLAUDE.md began claiming the item store lives at `$OFFICER_ROOT/permissions`.
It does not; that directory is on disk and full of skills and tools.
And the offscale doc's own note about the collision became
"Named `permissions`, NOT `permissions`" — a sentence that had eaten the thing
it existed to warn about.
Both restored, and the note rewritten to say what is now true: capability means
one thing of ours, and three that belong to somebody else's vocabulary.
Verified live after restart: self and admin permission endpoints 200, gated
route 200, agent-status 200, 9 grants intact with 6 permissions offered.
tsgo clean, 797 tests, 787 pass, same 7.
The rename is done. Four steps, no data lost, no client break that survived
the step it was introduced in.
|
||
|
|
5afa2d832e |
step 3/4: sidecar routing keys become handles — NOT YET VERIFIED LIVE
Committed before restarting, deliberately: this changes the registration wire, and the restart that proves it also bounces officer-claude-code, which is what the session doing the work runs on. An uncommitted 25-file protocol change is worse to inherit than one marked unverified. `capabilities: ['music']` → `handles: ['music']` on the registration message, across 20 sidecars, both plugins, the connector, the registry and the protocol type. findSidecarByCapability → findSidecarHandling, waitForCapability → waitForHandler. The name: a sidecar already has `handleCommand`, so the list is literally what it handles. `provider` was rejected — 390 existing uses and it already means websocket door. `serves` was rejected — collides with HTTP serving, which sidecars also do. THIS IS A BREAKING WIRE CHANGE with no compatibility shim. A platform expecting `handles` reads `undefined` from a sidecar sending `capabilities`, registers it with an empty list, and every sendCommand finds nobody — chat, terminal and music all fail with "No sidecar handling X is connected". So the whole estate has to restart together; there is no rolling upgrade. If it goes wrong: `git revert HEAD` and `pm2 restart all` again. The registry is in-memory and nothing about this touches the database, so a revert is complete. Found a fifth meaning of the word on the way, correctly named and untouched: the pty sidecar's terminfo capability queries (XTGETTCAP escape sequences). That is now five — permissions, the item store, routing keys, Lightning wallet features, and terminfo. tsgo clean, 797 tests, 787 pass, same 7. Not exercised against a live sidecar. |
||
|
|
5ec354cfcb |
step 2/4: role_capabilities becomes role_permissions, without losing a grant
The dangerous one. `drizzle-kit push` does not understand renames — it sees a
table gone and a table added, and with --force it resolves that by dropping and
creating. The 9 live grants would have vanished silently and every member would
have lost terminal, chat and files until someone noticed and re-granted by hand.
So the database moved FIRST, by explicit ALTER, and the code followed:
ALTER TABLE role_capabilities RENAME TO role_permissions
ALTER TABLE role_permissions RENAME COLUMN capability TO permission
ALTER INDEX uq_role_capabilities_role_capability RENAME TO uq_role_permissions_role_permission
ALTER TABLE ... RENAME CONSTRAINT ck_role_capabilities_{role,level,not_owner} TO ck_role_permissions_*
ALTER INDEX role_capabilities_pkey RENAME TO role_permissions_pkey
Seven objects, not one, and all in a single transaction: a partial rename would
leave drizzle-kit seeing a table it half-recognised, which is the same drop.
The names were read out of pg_indexes and pg_constraint rather than assumed.
`bun db:push` then reported **No changes detected** — which is the whole proof.
It means the ALTERs matched the schema code exactly, so there was nothing for
push to reconcile and nothing for --force to destroy.
Grants verified against a JSON backup taken before the first ALTER: 9 rows,
byte-identical, `role_capabilities` gone from the database.
No client noticed. Step 1 had already moved the wire to `permission` by mapping
in the route while the column still said `capability`, so this step deleted the
mapping rather than changing any response. The two call sites that carried a
`capability` key with a comment explaining why now say `permission` and the
comments are gone.
Also renamed officer_db/src/capabilities/ → permissions/, roleCapabilities →
rolePermissions, CapabilityLevelValue → PermissionLevelValue.
Verified live after restart: self 200, admin list 200, gated route 200, no
token 401, grants read back as {role, permission, level}, and a PUT round trip
left 9 grants intact.
tsgo clean. 797 tests, 787 pass, same 7.
|
||
|
|
2e8ec845c8 |
step 1/4: the permission engine is called permissions, not capabilities
The word meant four different things in this repo, not the three the offscale
doc records:
1. the permission registry → RENAMED here
2. $OFFICER_ROOT/capabilities/ items → kept; this is what capabilities are
3. sidecar routing keys → step 3, becoming `handles`
4. Lightning wallet features → kept; a domain term, and on the wire
to the mobile apps
The fourth was not in the doc and a global find-and-replace would have broken
the mobile wallet, which reads `{ kind, capabilities: Capability[] }` from the
wallet sidecar. So this renamed against an explicit file allowlist rather than
by sweeping the tree, and `CapabilityPage.tsx` — the UI for the item store, and
correctly named already — was left alone.
Moved: servers/capabilities/ → servers/permissions/, capability-gate.ts →
permission-gate.ts, users/capabilities-routes.ts → permissions-routes.ts,
hooks/useCapabilities.ts → usePermissions.ts. Identifiers follow.
Three breaks the typechecker could not see, all found by exercising it live.
The route paths moved with the prose sweep, so the server served
/user/permissions while the frontend still called /user/capabilities. A 404 on
every page load, and tsgo clean throughout.
The response FIELD moved too. `client.get<SelfPermissions>()` is an unchecked
cast, so `data.capabilities` became `undefined` at runtime with no compile
error — `can()` would have answered "no" to everything and the dock would have
emptied itself.
And the grants list was passed straight out of the database, so it arrived as
`{ role, capability, level }` while the screen read `grant.permission`. Every
role would have rendered as holding nothing. It is now mapped in the route:
the wire says `permission`, the column still says `capability`, and step 2
therefore changes nothing any client can see.
The stale react-query keys were the quiet one: two files still invalidated
['self-capabilities'] after the hook moved to ['self-permissions'], so
installing a plugin would have silently stopped refreshing the dock.
The database is untouched — `role_capabilities` and its `capability` column are
step 2, and the two call sites that cross that boundary say so in a comment.
Round-tripped the 9 live grants through the admin endpoint to prove the PUT
contract survived: 9 before, 9 after, Member's three intact.
Also reverted prettier churn on five landing-page files that a broad --write
picked up. Second time today; the lesson is not sticking.
tsgo clean. 797 tests, 787 pass, same 7 pre-existing failures — two of which
now read "path → permission" rather than "path → capability".
|
||
|
|
9b1c0a75b2 |
a dock tile can be bare, so artwork is not framed in a swatch
The tile background exists so a white lucide glyph has something to sit on — on nothing it is invisible. Artwork does not need it, and a logo framed in an arbitrary coloured square reads as a mistake. badge rounded square filled with `color`, icon inset (what a glyph needs) bare no background, artwork fills the tile (what a mark wants) DEFAULTED from whether the plugin ships assets/icon.png — artwork gets bare, a glyph gets badge — with `tile` on the manifest to override either way. Presence is the declaration, as everywhere else here; the field exists only for the plugin whose artwork genuinely wants a backdrop. Music sets nothing and gets bare. `color` is still required either way. It does four jobs and only one of them is the square: the active glow, the indicator dot and the plugins-list tint all read it too. The icon was also smaller than it looked. The source carried ~8% transparent margin on every side and the artwork is 233x221 inside a square frame, so with the old `h-7 w-7` inset inside a `w-10` box the duck occupied roughly 39% of the tile's area. Cropped to its alpha bounding box, padded back to square to keep aspect, and rescaled: it now fills 95% of the frame instead of 79%, and `bare` gives it the whole tile instead of half. Measured rather than eyeballed — the bounding box came from extracting the alpha plane and scanning it, then the crop was applied to the 1254px original so nothing was resampled twice. tsgo clean, 797 tests, 787 pass, same 7. Verified live: music reports tile=bare, example and offscale still badge. |
||
|
|
f78abbe05a |
a plugin ships its own dock icon as a file, not a lucide name
The manifest's `icon` was a lucide NAME, resolved by `resolveIcon` — which knows 106 glyphs out of lucide's ~1,500. A plugin naming one outside that set silently rendered a neutral box, and a plugin from a marketplace had no way to see the ceiling coming. So the icon is a FILE now: `plugins/<name>/assets/icon.png`, discovered by presence like everything else here. `manifest.icon` stays as an optional fallback for a plugin with no artwork — example and offscale still use it — and the file wins when both exist. No new mechanism was needed. The app store already published sidecar assets: `<dir>/assets/` → `public/plugins/<id>/`, served by a dynamic `/plugins/*` route, with `DockItem.image` rendering an <img>. `pluginDockManifests()` simply never emitted `image`. Install now publishes and uninstall unpublishes — the one thing uninstall is allowed to delete, because these are copies whose originals are still in the plugin's source. Base64 in the manifest was considered and dropped. It would ride in every /api/user/capabilities response for every user on every page load, can't be cached separately, and puts a 5KB string literal in a source file — against the rule this manifest keeps: what a directory listing can say, it says. It also needs no support: `image` goes straight into <img src>, so a data: URL already works for anyone who wants one. Music ships OffMusic.png, resized 1254² → 256² (1.6MB → 108KB) with alpha intact, sized for 3× DPI at the dock's 32px render. It also drops `icon` — the artwork is a voxel duck in headphones, not a glyph. The plugins page showed a generic Puzzle for every plugin; the list and detail header now show the plugin's own icon when it has one. Two bugs found while testing. Dock.tsx imported 26 lucide icons and used 14. The twelve dead ones — Music, Bitcoin, Receipt, Images, CalendarDays, Contact, Clapperboard, Mail, Network, ArrowDownUp, FileText, FolderKanban, MonitorSmartphone — were residue from when every feature had a hardcoded tile. Deleted. And uninstalling a plugin left its icon URL answering 500, not 404. server.tsx globs ./public at BOOT into one exact route per file, each holding a Bun.file handle, spread into the route table AHEAD of the /plugins/* wildcard. So an icon present at boot got an exact route that outlived the file, returning ENOENT on every dock render with the error logged each time — exactly what the wildcard's own comment says it exists to prevent. The comment covered the ADD case; this is its mirror. `plugins/` is now excluded from the boot glob, so the wildcard owns that prefix alone. Verified: 200 installed, 404 uninstalled, 200 reinstalled. [open] A plugin's icon cannot be seen BEFORE installing it, which is the one place an app store most wants to — assets are published at install by design, and an authenticated icon route is no use to an <img>. |
||
|
|
e11e0b6475 |
one port announcement instead of sixteen, so a plugin can name itself
protocol.ts declared a separate SidecarEvent member per sidecar — music, vault,
slskd, headscale, transmission, invoiceshelf, jellyfin, photos, memos, gitea,
caldav, wallet, pty, email, notify, opencode — each an identical one-liner. Two
of them were for plugins that had already left the repository, which is the
tell: the platform was declaring event types on behalf of code it no longer
contains.
The cost was that a plugin could not announce its own port. `connection.send`
takes a SidecarEvent, so `{ type: 'notes:server', port }` did not typecheck
until someone added a line to the platform. A third-party plugin needed a pull
request against Officer before its sidecar would compile — for the one message
every HTTP sidecar has to send.
Now:
export type SidecarPortAnnouncement = { type: `${string}:server`; port: number };
Nothing was lost at the point of use. The only consumer is
`sidecar.on(`${name}:server`, …)` in create-proxy.ts, which already cast to
read `.port`, because a handler typed `(event: SidecarEvent) => void` cannot
narrow from a computed template string.
The proof is a test that already existed: create-proxy.test.ts announces
`testcar:server` — a name never in the union — and now compiles without its
`as never`.
Removing the two casts that escaped the old type found something else. They
were not hiding union membership, they were hiding `port: number | undefined`:
Bun types Server.port as optional because a unix-socket server has none. So
`as SidecarEvent` on the whole object was suppressing a real nullability
warning, and would have suppressed a genuinely wrong event shape too. Replaced
with a narrow assertion on the port alone. Two further `as SidecarEvent` casts
in the same file turned out to be unnecessary altogether and are gone.
[open] The typo check is genuinely gone: a sidecar sending `muzik:server` while
its proxy listens for `music:server` now compiles, and the symptom is every
request answering 503 forever. The fix is not to restore the list — it is for
createSidecarConnector to announce the port itself from the `name` it already
holds, so the string is written once and the typo becomes unrepresentable.
Recorded in protocol.ts.
Verified live: officer and officer-music restarted, `[music] sidecar registered
on port 38803`, /api/music/manifest and /api/offscale/_health both 200.
tsgo clean, 797 tests, 787 pass, same 7.
|
||
|
|
a9bf51407e |
cliamp moves into the plugin, and the platform loses its last music file
The owner read the code and asked why `plugins/music/api/router.ts` was three
lines importing `@@/api/music/router` — platform code that knows the string
'music'. He was right, and tracing it found the justification was hollow.
The chain: server.tsx:20 imported the cliamp relay's two exports, which are
used only on commented-out lines; so the relay's functions were never invoked;
so its call to getMusicServerWsUrl never ran; and the file's other export,
getMusicServerUrl, had no consumers at all. A dead import held a music-named
file in the platform, and I documented that as a "seam" last night after
checking the import existed and stopping there.
Everything cliamp now lives in plugins/music/cliamp/:
sidecar/music/{cliamp-ws,pulse-audio}.ts, asoundrc, the test
api/cliamp/relay.ts
apps/FileBrowser/{CliampPanel,AudioStreamPlayer}.tsx
src/servers/sidecar/music/, src/servers/api/cliamp/ and src/servers/api/music/
are gone. server.tsx has no cliamp import, provider name, handler entry or
route. The platform contains no file named for music or cliamp.
Two of the things that moved were live, not inert.
The file browser's `Play` context-menu item, on any audio file or folder, set
?play= and rendered a cliamp terminal pointed at /api/cliamp/ws — a route that
upgraded into a handlers entry that was commented out, so handlers[provider]!
asserted non-null on undefined. Using that menu item crashed the socket
handler. Removed: the action, the layout, the panel wiring and both menu
entries. Verified the routes now 404 rather than crash.
That closed the totality drift as a side effect. server.tsx's route table and
its handlers map agree again for the first time since 2026-08-13, and
registry.test.ts now asserts it rather than pinning the hole.
The proxy is built in the plugin now, and its prefix is DERIVED. It was the
literal '/api/music', which the proxy uses to strip characters off the path —
correct only because mountPrefix returns /music for a first-party publisher.
The same plugin published by anyone else mounts at /api/p/<publisher>/music and
would have forwarded /alice/music/stream to a sidecar expecting /stream. A
latent bug only third parties would ever hit, and a quiet violation of the rule
that mountPrefix is the one function allowed to know about provenance. Offscale
has the identical hardcode and still needs it.
Still open there: appName is passed as a literal, because a plugin's router
cannot see its own directory name — the platform imports the module and reads
`router`, so there is nowhere to inject it. The fix is a factory the installer
calls with the plugin's identity.
Plugin backend coupling is down to 7 imports, all of them "a plugin talks to
its host": data-path, sidecar/connect, sidecar/protocol, officer-url, the
manifest type, officerdb/db and the users.id FK. Nothing music-shaped left.
bunx tsgo clean. 797 tests, 787 pass, same 7 pre-existing failures. Verified
live: manifest 200, favorites 200, stream 206, /api/cliamp/ws 404.
|
||
|
|
e930586878 |
plugins declare the host binaries they need, and the installer checks
Offscale was self-sufficient. Music is not — it shells out to ffmpeg and ffprobe — and the way it fails without them is the reason this is a check rather than a line in a README. It does not fail. Missing ffprobe means the indexer catches the spawn error and returns a track carrying its filename and nothing else: no title, artist, album, duration or embedded lyrics. It then walks the whole library, writes a complete cache tree and reports success. Five swallowed catches, no log, no counter, and the only tell is coversSaved: 0 in a report nobody reads. So `osDependencies` is a manifest field: the binary to probe on PATH, why it is needed, and a package name per package manager. The shape is taken from scripts/setup-old/setup.sh rather than invented — probe the binary, case on $PM — and the names are per-manager rather than canonical-with-overrides because lib/packages.sh already recorded why that indirection was rejected. Probing the binary is what makes "built-in on this OS" free: on PATH means the package map is never consulted. Four decisions worth naming. Missing and uninstallable REFUSES the install, first, before a table is created or a row written — so there is nothing to undo, and the alternative is a plugin that installs, answers 200 and quietly produces nothing. The status is on GET /api/plugins and rendered before the button, because the owner is deciding whether to let the server run a package manager as root and that needs answering first. Installing by hand and watching it flip to present is the escape hatch on a machine without passwordless sudo. Package names get a deliberately narrow regex and reach Bun.spawn as an argv ARRAY, never a shell. Both halves are load-bearing: the regex means a metacharacter cannot get there, argv means it would be an argument rather than syntax if it did. Narrower than package managers actually accept — no `:`, no `+` version pins — because a plugin needing one wants a conversation. Success is OBSERVED, not inferred: after installing, the binaries are re-probed. A package manager exiting 0 having installed something that does not provide the binary is exactly the failure this exists to catch. installCommand mirrors lib/packages.sh's pkg_install_now exactly, including apt's non-interactive environment, so there is one definition of "install a package" rather than two that drift. sudo always gets -n: under PM2 a password prompt is not a slow path, it is a hang. brew never escalates. Verified live. ffmpeg and ffprobe were absent on this machine all evening; the page showed both missing with the exact root command, the install streamed `dependencies: installing ffmpeg with apt` then `ffprobe, ffmpeg now on PATH`, and X-Audio-Duration appeared on a stream response for the first time. The refusal path was exercised against a temporary probe dependency: HTTP 400, steps: [], reason named. THIS CHANGED THE MACHINE: ffmpeg 6.1.1-3ubuntu5 is now installed via apt. Found on the way: a manifest is read once per process. Discovery does `await import()` and the module cache holds it, so editing a manifest changes nothing until pm2 restart officer — including `outdated`. Cost ten minutes and is now in the runbook. bunx tsgo clean. 797 tests, 787 pass, 7 fail — the same seven, +25 new. |
||
|
|
de3340398c |
music becomes a plugin, and the player stays behind
The whole of music moves to plugins/music/: the sidecar (index, indexer,
stream-audio, nightly-reindex), the four Postgres tables and their queries,
the /music workspace panels, MUSIC_API.md and the reindex CLI. The platform
keeps no music routes, no music capability entry, no music screen and no
music schema.
Three things stayed, each on purpose.
cliamp and the widget were out of scope by the owner's decision. The plugin's
sidecar still serves the two cliamp sockets, so it imports cliamp-ws.ts and
pulse-audio.ts from @@/sidecar/music/ — the files stay where they were.
The player did not move, and that was the open judgement call. Deciding it
took one fact: the dashboard widget imports useMusicPlayer and PlayerTrack
from officerdev, and the platform cannot import from a plugin. So the player
STATE stays whatever is decided about the UI around it, and two copies would
mean two audio engines. Given that, the engine and the bar stayed with the
state rather than being split from the thing they drive. Moving them would
also have needed a shell slot rendering a plugin-provided component on every
route — the one escape hatch this system deleted on purpose. MusicPlayerHost
gates on can('music'), which is now the plugin's permission, so the seam
switches itself off with the plugin.
api/music/router.ts stays too: api/cliamp/relay.ts imports getMusicServerWsUrl
from it. The plugin's api/router.ts re-exports that proxy rather than building
a second one — two subscribers to the one-shot music:server port announcement
would work today and 503 on the first reconnect where only one was listening.
Two bugs found on the way, neither visible from reading.
The app-store catalogue still listed music. Availability is derived from
sidecar_installs and a PLUGIN never gets a row there, so `music` would have
been permanently unavailable — which puts /music into deniedRoutes and blanks
the screen on a server where the plugin was installed and healthy. Exactly
the headscale bug documented six lines above it in the same file, and it would
have fired on the first install. Entry removed.
[test] root was "./src", so moving lyrics.test.ts into plugins/ stopped it
running and said nothing — the count fell by nine and the suite still read
green. Root is now the repo. Positional filters cannot fix this: `bun test
plugins` matches under root and finds src/servers/plugins/ instead.
registry.test.ts tested the `personal` mechanism THROUGH the music capability.
Re-anchored on a fixture rather than on another entry, because borrowing a
feature only moves the problem to the next extraction — and three of those
four tests had been passing for the wrong reason since music's api was
commented out on 2026-08-13, when everything started resolving to "refused
because nothing is claimed". The cliamp sockets being claimed by nothing is
now pinned by a test instead of being rediscovered.
music's `personal` paths ride across on readOnlyWrites, the one field a
manifest has. isRequestAllowedAtLevel concatenates the two lists, so a read
grant permits exactly the four paths it permitted yesterday, and no field was
added to the manifest to design a per-user model that is not this work.
bunx tsgo clean. 772 tests, 762 pass, 7 fail — all seven pre-existing and
unrelated (cliamp, pty, and five capability tests that other switched-off
plugins break). Baseline was 757/10; the three that went green are the ones
re-anchored above.
Not yet verified on the live server — that is next.
|
||
|
|
4a9f23c759 |
the design doc moves into the plugin it produced
docs/offscale-plugin.md becomes plugins/offscale/PLUGIN.md. most of it is about the plugin system generally rather than about offscale, which is exactly why it belongs with the worked example — the reasoning is most useful beside the code it produced, and the platform's docs should not carry the history of something it no longer knows exists. every reference updated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
585c046a64 |
the plugin system says permissions, not the other word
i named the field permissions and then narrated in the overloaded one anyway, which is worse than either — the whole reason for the rename was that the word already means three things here. renamed what was mine: setPluginCapabilities -> setPluginPermissions, pluginCapabilities -> pluginPermissions, and the prose throughout. what remains is the platform's own vocabulary, not the plugin system's: the type it imports, and the field on a dock manifest, which is the shape the shell already renders. renaming those is a separate change to a separate system and is the owner's to make. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
8cc51cfb40 |
every plugin permission is grantable, and there is no field to say otherwise
offscale was missing from the permissions page because it declared ownerOnly, which mapped to kind admin, and admin capabilities are never offered for granting. correct by the old rule and wrong by the standard: every plugin follows the same platform-level permission model, appearing on the same page with the same read/write/none per role. so the field is gone rather than flipped. a plugin has no way to say owner-only, which makes the standard structural instead of remembered — the same move as the workspace rule. core, execution, confined and admin stay the platform's to assign and a plugin cannot name any of them, so the escalation question is removed rather than answered. this is the second draft of this decision to be deleted: first a full CapabilityKind with three of five values forbidden, then an ownerOnly boolean, now nothing. the doc records all three so the reasoning is visible rather than just the conclusion. finer visibility stays the plugin's job. offscale is the worked example of the gap that leaves and its manifest says so: it is grantable now, and its queries still scope by the caller, so a granted member would see their own empty server list rather than the owner's. closing that is a change inside the plugin. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
8b6cb34ae0 |
plugins contribute dock tiles, and the doc says what is actually built
offscale had no dock tile: that field comes from the app store's catalogue, so a plugin installed through the plugin system was reachable only by typing its url or following the link on /plugins. built at runtime rather than baked into the generated bundle, deliberately — WHO sees a tile is a permission question, and a grant takes effect on the next request rather than the next build. presentation comes from the manifest and the route from mountPrefix, so there is one source for both, and is the plugin's first permission so the endpoint can filter a tile out for an account that cannot reach the screen. two sources for tiles today, because the app store still has its own catalogue. one when it is rebuilt on this. the doc now records the system as complete rather than half-stubbed, including the three bugs the extraction found — the sidecar-before-mount ordering, the missing tailwind, and the build that could delete its own shell — and what is genuinely still open. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
8587ae20b7 |
a plugin creates its own tables, and uninstalling never drops them
the last unwired step. install now generates a drizzle barrel of plugin schemas and runs db:push, so a plugin with db/schema.ts brings its tables with it. the barrel follows the plugin DIRECTORIES on disk, not the install table, and that difference is the entire safety property. push drops what it cannot see, so a barrel tracking installs would delete a plugin's tables the moment it was uninstalled — turning "stop running this" into "delete my data", which is the one thing the install model refuses to do. following the directory means: directory present, not installed in the barrel, tables exist unused installed in the barrel, tables in use uninstalled STILL in the barrel, every row survives directory deleted out of the barrel, a push may drop them so reinstall is a restore, and losing data requires deliberately deleting a plugin's source. proved end to end rather than argued. with offscale uninstalled and its entry removed from the barrel, db:push DROPPED headscale_servers. installing it recreated the table in 1882ms — columns, both unique indexes including the partial one that enforces a single active server, and the fk. a canary row then survived an uninstall AND a subsequent manual db:push, which reported "No changes detected". it shells out to the same `bun db:push` a human runs rather than driving drizzle-kit in-process: one definition of applying the schema instead of two that can disagree, and an owner can reproduce exactly what an install did. --force because the barrel only ever gains entries unless source is deleted, and a prompt with no terminal would hang an install rather than fail it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
e13128846b |
offscale is a plugin
headscale leaves the platform. 45 files move to plugins/offscale/ and the
platform stops knowing it exists.
api/router.ts the thin auth-gated proxy, now at /api/offscale
sidecar/ 18 files, the whole headscale contract and its admin keys
db/ schema + queries, offscale_servers
web/ 26 files as panels and a layout — no screen, per the rule
removed from the platform: the hono mount, the `headscale` capability, the
App.tsx route pair, the screen and its barrel, the AppRegistry spread, the
officerdev re-exports, the dock tile, the page-title rule, and both database
barrels. tsgo is clean and nothing references it.
the imports tell the story of what the plugin↔host API actually is. the sidecar
takes @@/sidecar/protocol, @@/sidecar/connect, @@/data-path and
@@/officer-url.mjs; the queries take officerdb/db and officerdb/crypto; the
schema takes officerdb/auth/schema for the one reference a plugin may make; the
web half takes useClient, copyToClipboard, WorkspaceView and TerminalView from
the officerdev barrel. all of it resolves because a plugin lives inside the repo
— no publishing, no version negotiation.
AND IT FOUND A REAL BUG IN THE INSTALLER. createSidecarProxy learns its port
from a one-shot `<name>:server` event and subscribes when the plugin's router is
first imported — at mount. install started the sidecar BEFORE mounting, so the
announcement fired into a void: process online, routes mounted, every request
answering `503 sidecar not available` until something forced a reconnect. it
would have hit every plugin with an http sidecar. `example` never caught it
because it has no listener to announce.
install and enable now mount before starting; disable still unmounts before
stopping. neither direction leaves a mounted route in front of a sidecar that
cannot be reached.
verified live: /api/offscale/_officer/servers answers {"servers":[]}, /offscale
and /offscale/nodes serve, the old /api/headscale is 404, the offscale
capability is registered from the manifest, and officer-offscale is online.
757 pass, same 10 pre-existing failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
543e88a9a6 |
every plugin route renders a workspace, and it is not a rule you can forget
an exclusionary rule, made structural. a plugin does not render a screen: it
contributes panels and says how they are arranged, and the shell renders
WorkspaceView around them.
web/panels.ts appRegistryMetas — at least one panel
web/layout.ts defaultLayout — how they are arranged
both required the moment web/ exists, and missing either is refused at discovery
by name and with the reason. tested:
probeplug: has a web/ directory but is missing web/layout.ts.
Every plugin route renders a Workspace: contribute panels and a layout,
not a screen.
there is deliberately no way to export a component. one that could would be free
to render a bare div, a full-page form, or its own navigation, and the platform
would become a shell hosting strangers' layouts rather than one application.
non-compliance is not so much refused as unrepresentable — there is nowhere to
put a screen.
the shell registers <prefix> and <prefix>/:section, exactly as the core screens
do, so a plugin's sections stay addressable and cmd-clickable, and panels read
useParams independently rather than passing state between themselves.
appTypes.allowed is pinned to that plugin's own keys, so a persisted layout
naming something else falls back instead of rendering another plugin's panel
inside this screen.
the example plugin is rebuilt to model it — two panels, a layout, one of them
calling its own /api/example/ping through useClient — because the reference
implementation is what everyone copies.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
2e3c935da6 |
the built SPA had no tailwind, and the build could destroy itself
three fixes to last night's build switch, all found by using it. THE CSS. bunfig.toml declares the tailwind plugin under [serve.static], which applies to bun's static SERVING — the html-import path the app used until yesterday — and not to a programmatic Bun.build(). so the first build emitted the xterm css and no tailwind at all: layout intact, every utility class missing. a plugin list is not inherited from bunfig; it has to be passed. css goes 110KB to 278KB, 1127 --tw- variables, .flex present, --color-duck present. THE BUILD DIRECTORY. clearing it before building was meant to stop 20MB of content-hashed chunks accumulating per install, and instead meant a FAILED build left nothing — the exact opposite of the promise in the comment directly above it. it was also a race: two builds overlapping had one process's rm delete the other's shell, leaving js and css with no html and a 503 that read as a build failure when the build had succeeded. now it builds into build.next/ and swaps only a complete, successful build into place, and refuses to swap one that produced no shell at all — a build can report success and emit no html, and serving that is worse than serving the previous one. AND IT SAYS WHICH PATH IS SERVING. "is it serving the build I just made, or the one bundled at import?" was answerable only by hiding the shell and watching for a 503, which is how it got answered once. the distinction matters precisely where it is hardest to see: the html import is fixed when the module graph loads, so an install would rebuild build/ and serve something else entirely. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
7b4137ccca |
a plugin's frontend, generated and rebuilt without a restart
the last piece. installing a plugin now brings its UI with it.
a bundler cannot follow import(runtimeString), so which plugins have a frontend
cannot be answered from the database at render time — it has to be written into
source first. Plugins.gen.tsx is that file: concrete imports, generated from
what is installed, gitignored because it describes THIS machine.
App.tsx keeps its core routes and gains one map. the wildcard hands the whole
subtree to the plugin's own router, which react-router nests natively.
serving moved to build/ in production. the html import is bundled once when the
module graph loads and can never change after, which is precisely why a plugin's
frontend needed a restart; Bun.build measures ~900ms for a 25MB bundle, so an
install can just rebuild. development keeps the html import, because that is
what gives HMR and bun --watch restarts on every source change anyway.
verified end to end against a running server, no restart at any point: install
regenerated the module, rebuilt the bundle (chunk hash changed), and the
plugin's own markup was in it; /example and /example/deeper both served; disable
took it back out of both the module and the bundle and 404'd the api; enable put
it back.
three things worth recording because they were found rather than reasoned:
the shell output is named after the ENTRYPOINT — index.gen.html, not index.html
— and naming: { entry: '[name].[ext]' } does not change it because [name] is
'index.gen'. found as a 503 on the first boot after the switch.
App.tsx already destructured a `plugins`, from useServerSettings — the DEAD
plugin system that scans a directory which does not exist and always returns [].
it silently shadowed the import. the new one is `installedPlugins` and says why.
seedAppRegistry takes plugin panels as an argument rather than importing them:
officerdev is a dependency of the shell, so importing upward would invert that.
756 pass, same 10 pre-existing failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
a00116b2c0 |
a plugin's permissions become real capabilities, and survive a restart
two gaps between "a plugin can mount routes" and "a plugin is part of the platform". both closed. FIRST: nothing registered a plugin's declared permissions, so the capability gate could not resolve a plugin path at all. it resolved to null, and null is denied — the owner never noticed because isSuperAdmin short-circuits every check, which is exactly the shape of bug that reaches a member first. the registry is now rebuildable the same way the hono app is: CORE_REGISTRY holds the platform's own, CAPABILITIES is core plus whatever the installed plugins declare, and setPluginCapabilities replaces the plugin half wholesale rather than diffing it. two invariants hold by construction — DEFAULT_ROLE_- CAPABILITIES and CORE_CAPABILITIES derive from CORE_REGISTRY, so a plugin can never put itself in the fresh-install baseline and can never become `core` (every account, undeniable). a key colliding with a core one is refused and logged, because a plugin able to redefine `chat` could widen it. ownerOnly maps to admin, everything else to app. those are the only kinds a manifest can express, and it has no field for a kind at all. capabilities are registered BEFORE routes are mounted: the gate runs ahead of every router, so mounting a route whose permission is not yet registered would 403 the freshly installed plugin until something else happened to refresh. SECOND: nothing mounted plugins at boot. honoServer is built with none at import, because discovery reads disk and database and neither can be awaited at module scope, and every install verb rebuilt — so it tested perfectly and would have silently unmounted everything on the first restart. server.tsx now refreshes before serve(), so there is no window where an installed plugin 404s, and a plugin that will not load is logged rather than fatal. verified: after a restart, [plugins] mounted /example, the row survived, officer-example came back online from its ecosystem entry, capabilityForApiPath resolves /api/example/ping to the example capability at kind=app, and it appears in the owner's grantable list. 756 pass, same 10 pre-existing failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
62ee0d1e60 |
the beat between steps is feedback, not decoration
the comment called it cosmetic and worth being honest about, which reads like an apology and invites the next reader to delete it as a pointless sleep. the real reason is better. some of this work is genuinely slow — pm2 start measures ~770ms — and some is effectively instant. without a pause the fast steps land in one frame, the log jumps from empty to finished, and you cannot tell 'it worked' from 'nothing happened'. the interval is what makes a step something you saw happen rather than something you found already done. only applied when something is listening, so the json path still runs flat out. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
4d4606d4a2 |
close the owner's dotfiles once somebody else has a shell
ubuntu's default umask is 002 with user-private groups, so everything the owner
creates lands 775/664. alone on a machine that is harmless. it stops being
harmless the moment a member has a login — and member homes are NESTED inside
the owner's, so the owner's home must stay traversable AND readable (the
ancestor-read requirement bun exposed today) and every dotfile in it is legible
by default.
measured as green before writing this: ~/.pm2/logs (all 12 files, every log the
platform has written), ~/.pm2/dump.pm2, ~/.claude/projects (names every
directory the owner works in), ~/.config, ~/.local, ~/.cache, ~/.npm, ~/.bun,
~/.opencode — all listable. assertSecretsClosed was already holding the line
that matters: .env, .ssh, .zsh_history, .claude.json and the credentials are
denied, and dump.pm2 turned out to hold no secret values because bun loads .env
at runtime rather than through pm2.
so this is the tier below fatal: not tokens, but logs and the shape of the
owner's work.
it runs from PROVISIONING, not from setup, and that is the point. ~/.claude does
not exist until the agent has run once; a chmod at install time finds half the
list missing and silently does nothing — the same failure mode as the ACL mask
earlier today. every member's arrival re-closes whatever appeared since.
two directories are left open on purpose, and both are the same latent bug:
/usr/local/bin/bun -> /home/pastilhas/.bun/bin/bun
/usr/local/bin/gh -> /home/pastilhas/.local/bin/gh
system-wide tools installed into one user's home, so every member resolves them
through it. i found this by closing them and breaking bun and gh for green.
~/.local/share and ~/.local/state ARE closed; only the bin directory is
reachable. the honest fix is installing them outside the owner's home.
verified both directions on this host: green is denied .claude, .pm2, .config,
.local/share, .local/state, .cache — and still has working bun, gh, psql, their
own claude, and their own project tree.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
a220342b22 |
stream the install, so it reads like a log instead of a spinner
each verb now reports its steps as they complete, over server-sent events, and the detail panel renders them arriving. POST rather than GET, so EventSource is unavailable — it sends no Authorization header and these routes are owner-only. The client reads the body and parses frames by hand, which is what useCompanionLogStream already does for the headscale container logs; the parser only has to understand what our own endpoint emits. the runner does not know whether anyone is listening. it takes an optional onStep and calls it, so the non-streaming path is the same code with no callback rather than a second implementation of the same four verbs. there is a 220ms beat between steps and it is cosmetic — worth saying out loud. pm2 start genuinely takes ~770ms, measured, but writing a row and rebuilding the router do not, and four lines landing in one frame look like a stall followed by a jump. small enough not to matter to a script, long enough to follow. writing to a closed stream is caught rather than fatal: navigating away mid-install must not abort the install, because by then it is the server's work and half an install is the one outcome the ordering was designed to avoid. verified over the wire with timestamps — frames arrive incrementally, the sidecar step showing its real duration rather than the beat. afterwards pm2 holds the five core apps, plugin_installs is zero, and ecosystem.config.cjs is byte-identical. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
02e049cae8 |
terminal: stop replaying questions, stop opening two sockets, bind the word keys
three separate faults behind "reconnecting gets weird and the keyboard is not natural". ── the replay typed into the shell ── the pty buffer was stored raw and replayed verbatim on every re-attach. anything in it that ASKS the terminal a question — DSR, DA, DECRQM, XTVERSION, XTGETTCAP, the OSC colour queries — got asked again, and xterm answered correctly by writing the reply to its input. the pty receives that as a keystroke nobody typed. stripped on the way IN, since the buffer is the thing that gets replayed and a live client already answered them once when they were legitimately asked. only questions are removed; everything that draws is untouched. where a control shares its final byte with one that draws, the parameter is enumerated rather than wildcarded — CSI 18 t asks the window size, CSI 22 t pushes the title, and stripping the second would change what a replay renders. 36 tests, both directions, because both fail silently. ── two sockets on one session ── handleClose armed a reconnect timer; handleVisibilityChange fired on tab focus whenever readyState was CLOSED — which is exactly what a pending timer leaves. both ran. every keystroke went twice, two replay frames fought over the screen, and only one socket was ever cleaned up because __terminalCleanup is overwritten by whichever connect ran last. connect() is now the single guard, and a stale socket's close no longer speaks for the session. ── the keyboard ── alt-arrow was dead for everyone: xterm.js 5 rewrote it into the ctrl-arrow sequence, xterm.js 6 removed that rewrite and emits the honest ^[[1;3C/D (verified — the string 1;3D does not appear anywhere in the 6.0 bundle). nothing bound it. so it broke on a dependency bump, with no shell config changed. bound in zsh rather than translated in the browser, deliberately: tmux.conf claims M-Left/M-Right for pane switching, and a client-side rewrite would send ^[b to tmux and break it. the real sequence lets tmux handle it inside a session and zsh outside. ctrl-arrow was worse and more embarrassing: it worked for MEMBERS and not for the OWNER. shell-skel/zshrc has had the bindings all along; the owner's .zshrc is assembled in machine-setup and never got them. the owner had a strictly worse shell than the accounts they provision. confirmed with `zsh -i -c bindkey` before and after. also: escape-time 10 in tmux.conf. the 500ms default delays every Alt chord and every Escape, which is most of what "not natural" felt like. applied to this host by hand — setup only runs at install. cmd+arrow is left alone: xterm emits nothing for it, so there is no sequence to bind. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
2634df7a04 |
the install runner: ecosystem entry, sidecar, row, mounts
closes the hole app-store/pm2.ts has carried since 2026-08-13 — "installing a
plugin has to append its entry here before starting it, that is the plugin
system's job and it is not built". this is that job, and it is why nothing in
the app-store catalogue installs end to end either.
verified against a running server with a sidecar in the tree:
install ecosystem added · sidecar started · recorded · mounted /example
route 200, pm2 online
disable sidecar stopped · unmounted
route 404, pm2 stopped
enable sidecar started · mounted
route 200, pm2 online
uninstall record removed · unmounted · sidecar stopped, deleted, entry gone
route 404, not in pm2, tables untouched
afterwards ecosystem.config.cjs is byte-identical to before, pm2 holds the same
five core apps, and plugin_installs is back to zero rows.
the ecosystem file is edited rather than regenerated: the core entries come from
officer-setup's shell array, so the platform does not know that list and a copy
here would be a second thing to drift. the header above module.exports is
preserved verbatim too — officer-setup's explains that bun auto-loads .env from
the working directory and that data-path derives the install root from its
PARENT, so a wrong cwd relocates the whole install rather than failing. losing
that to a plugin install would be a poor trade.
order is the design. bringing up goes outside-in, taking down goes inside-out,
so the worst intermediate state is "recorded but not running" — visible, and
fixed by a retry — never "running but forgotten", which nothing can see.
each verb returns what it actually did, in order, and the detail panel shows it.
an install that mounted routes but could not start a sidecar is a different
outcome from one that worked, and a spinner that stops cannot say which.
the schema push is still deliberately not wired, and the reason is now in the
code: db:push DROPS tables absent from the schema it is given, so an uninstall
that regenerated the barrel would delete a plugin's data as a side effect of
stopping it. offscale does not need it — headscale_servers already ships in the
platform schema.
720 pass, same 10 pre-existing failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
eb1fd8c31a |
headscale is core, so stop offering to install it
Reported as: the routes are unreachable and there is no dock tile, on a server where officer-headscale is up and healthy. Both symptoms, one cause. Availability is derived ONLY from the sidecar_installs table — `usable` is the rows with status='installed' AND enabled, and every capability mapped to a sidecar outside that set is added to `unavailable`. A CORE sidecar never gets a row there, because core processes are started by pm2 from the generated ecosystem file and never go through the app store. So `headscale` and `vpn` were permanently unavailable, which withheld the dock manifest AND put /headscale into deniedRoutes for the route guard. The design already knew. catalogue.test.ts has a test called "does not offer to install the baseline", and it has been FAILING since headscale was promoted: Expected to not contain: "officer-headscale" docs/secret-store.md predicted it in as many words — "moving headscale into the light profile also removes it from the app store automatically: catalogue.test.ts asserts the catalogue equals full − light, so the test fails until the entry is deleted". The entry was never deleted, and the failing test was never read. So: entry removed, and the tile moved to CORE_DOCK_ITEMS, where the other things that are always present live. DashboardLayout filters every tile through canVisit(), so a member still never sees it — the capability is kind: 'admin'. The entry's existingFields (URL + API key) are not lost. Servers are added from the Servers view inside the app — ServersView.tsx, ServerForm.tsx, useHeadscaleServers.ts — which is where they were really configured; the app-store form was a second place to type the same two values. Verified: catalogue.test.ts 19 pass/1 fail → 20 pass/0 fail, tsgo clean. PRE-EXISTING, not touched: 10 other tests fail on master, 8 of them in src/servers/capabilities. Confirmed identical before and after this change by stashing it and re-running. Worth a look but not this change's business. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
b6feca8350 |
owner can reset a member's platform password
The gap at the other end of create-user.ts: the owner could set a password once, at creation, and never again. Losing it meant a hand-written UPDATE with an argon2 hash — the same "edit Postgres by hand" hole that creating accounts used to have. POST /api/users/:id/password, owner-gated, with a button on the row. GENERATED, not typed. The failure this exists for is "I created the account and forgot to copy the password down", and an owner typing a replacement can lose it the same way on the second go. Shown once in a dialog built to be copied — a dialog and not a toast, because a toast that times out while somebody finds a pen loses the one thing they came for. The generator satisfies validatePassword BY CONSTRUCTION rather than by luck: one character drawn from each of the four required classes, the rest from the union, then Fisher-Yates shuffled so the first four positions are not always lower/upper/digit/special. Rejection sampling throughout — `% n` on a byte biases the early characters. Then it runs validatePassword on its own output, so if the rules ever gain a requirement the alphabets do not cover it throws at the one call site instead of minting passwords the login form rejects. Measured: 20,000 generations, all four classes present every time. l, I, 1, O and 0 are absent from the alphabets. This gets read off a screen and typed somewhere else. Signs them out everywhere, as asked: passwordChangedAt = now, and userMiddleware already refuses any token whose iat predates it. That overwrites the null create-user leaves to mean "the owner chose this, not them" — checked, nothing reads that column except the token check. The Linux account is deliberately untouched, and the dialog says so. Members have no Linux password and never had one: ensureOsUser runs useradd with no -p, so it is created locked. Their terminal goes through setpriv, which does not authenticate; their SSH is the key the owner pasted; `su - <member>` as root does not ask. And machine-setup sets PasswordAuthentication no — verified on this host — so one could not be used to log in even if it existed. Setting one would be a new way in, not a repair. The owner is excluded: they have change-password, which asks for the current one, and resetting themselves here would end the session doing it. Verified: transpiles, all lucide icons exist, 20k generator runs. tsgo next. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
e881015df5 |
members get the same aliases as the owner
The set that just went into the owner's zshrc, mirrored into shell-skel/zshrc, so a
shell on this machine and a shell in a member's account behave alike rather than
diverging by who you happen to be.
Not a copy-paste. Three differences, each because this file has rules the owner's
appended block does not:
- `n` and `vim` are NOT repeated. The Editor block above already sets them, and
only when nvim is actually installed — better than the owner's unguarded pair.
- the eza family keeps its `else` branch rather than only being guarded. A member
with no eza still gets a coloured, grouped listing instead of bare `ls`, and
every alias in the family has a real fallback: lll, lh, ltr and l were added to
that branch too rather than silently existing only when eza does.
- lazydocker is guarded like its neighbours duf and lazygit, per this file's
stated rule that nothing is required beyond zsh itself.
eza needs no separate install for members: they share the host, and machine-setup
puts it in the core package list.
KNOWN, same shape as append_once: seedShellConfig only rewrites .zshrc while it is
still byte-for-byte the template, so a member provisioned before this keeps the old
one. No members exist right now, so nothing to migrate.
Verified: zsh -n, and the fallback branch resolving all ten aliases with eza absent.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
fb0286e1a9 |
put rootless docker back, behind the developer gate
Uncommented at all three sites: the call and import in provisionOsAccount, the ~/.local/dockers bind-mount directory in confineUserTree, and the DOCKER_HOST block in the member zshrc. Not restored unconditionally, which is how it was before. It now sits behind the same Developer check as the Postgres role — the gate that prompted disabling it in the first place. So rolePermitsDatabase is renamed rolePermitsDevTools: it gates two things now and a name saying "database" while deciding whether you get containers is the kind of comment that goes stale silently. ~/.local/dockers is created for EVERY account rather than only Developers. It is two install calls, and confineUserTree is the function that places the layout, not the one that knows who is a Developer — so a member promoted later finds it already correct. Postgres role work is untouched and still in place. Verified: transpiles. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
bf7e919593 |
only a Developer gets a postgres role
The gate rootless Docker was under, which I did not know about when the Postgres role replaced it. It inherits the rule along with the purpose: a member not trusted to run containers is not thereby trusted to run databases. rolePermitsDatabase() is the single place that rule is written. Admin is deliberately NOT included — administering the platform is not developing on it, and they are separate roles precisely so they can be held separately. Say so if that is wrong. provisionOsAccount now takes the role. Two call sites: create-user passes what the owner picked, provision-linux-route reads it from the row — which makes that route the way a member promoted to Developer gets the database role they did not qualify for when their account was made. The half that makes the gate real is in updateUserRoleHandler. Without it the rule would decide what a Developer gets at creation and never look again, so demoting one would leave their role, their databases and a working password in their ~/.zshenv — a permission surviving its own revocation, with the UI then saying something untrue. Revoke before recording, grant after: the drop runs BEFORE updateUser so a failure aborts with the role unchanged and the whole thing retryable. Promotion runs after and is non-fatal, like every other provisioning step. Demotion keeps their data, same as deletion — databases are reassigned to the platform role, not dropped — and logs where it went, so it does not look deleted. KNOWN, not handled: a demoted member keeps a stale ~/.pgpass and ~/.zshenv naming a role that no longer exists. Harmless (the connection just fails) but untidy, and it means `cat ~/.zshenv` shows a password that no longer works. Verified: transpiles, both call sites updated. Still no tsgo. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
2ac58e2007 |
drop the postgres role when a member is decommissioned
It was written and never called — dropPostgresRole had zero call sites, so deleting a
member left their role and databases on the cluster.
That is the uid trap in a different id space, and worse. provisionPostgresRole ADOPTS
an existing role, so a role left behind is inherited whole, with its databases, by the
next member who gets the same username. useradd hands out the lowest free uid by
accident; the owner hands out usernames on purpose, so reuse is likelier here, not
less.
Rewritten to PRESERVE rather than destroy. The first version dropped the databases,
which is inconsistent with severMemberTree three files away — that chowns a member's
files to the service user rather than deleting them, and a database is the same kind
of thing. The owner removing an account has not necessarily asked to destroy the work
in it, and dropping is the one choice that cannot be walked back.
Needs the full idiom, per database, and both halves matter:
ALTER DATABASE .. OWNER TO REASSIGN OWNED does not move database ownership
REASSIGN OWNED BY .. TO .. moves tables, schemas, functions
DROP OWNED BY .. removes what is left, which after a reassign is
only the GRANTS — without it DROP ROLE still
refuses, an ACL entry is a dependency too
Both statements act only on the database they are connected to, so it is a connection
per database rather than a loop over `db`.
Ordered after the Linux teardown (which can fail and abort, and must not do so after
something irreversible) and before deleteUser (the row is what remembers there is
anything to clean up).
Verified live: a member with a database, a table, a row and a schema. Naive DROP ROLE
refused with "2 objects in database carol_app". After the sequence: role gone, row
intact, table owned by postgres. Then recreated the same username and confirmed she is
REFUSED from the old database — the login trigger holds because ownership moved.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
9f15de3448 |
put the postgres credentials somewhere findable
The password now lands in ~/.zshenv as PGHOST/PGPORT/PGUSER/PGPASSWORD, as well as in
~/.pgpass. Asked for on the grounds that it is an easier place to remember, which is a
real requirement — a credential you cannot find is one you will ask about every time.
.zshenv rather than the .zshrc that was asked for, for two reasons, neither about
secrecy:
- zsh sources .zshrc for INTERACTIVE shells only. Verified: `zsh -c` prints an empty
PGUSER when it is set there, and the right one from .zshenv. A script, a cron entry
or an agent turn running psql would silently get nothing.
- .zshrc is a shared template and seedShellConfig only updates it while it still
matches byte-for-byte, so members keep their edits. A per-member password in it
would strand every member on the template they were created with — a silent
maintenance break rather than a tradeoff.
Both files are still written because they are not redundant: .pgpass is what libpq
reads with no shell involved, so it is the one that works for psycopg, a systemd unit
or a compiled binary. The rotate condition now covers both — either missing means we
cannot reconstruct it from the other, so we regenerate.
Also drops the `head -1 ~/.pgpass` parsing I had put in the zshrc template. It was a
hack, and the values are in the environment before that file is read now anyway.
Verified: zsh sourcing order and both shell types, transpiles. Still no tsgo.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
3ca7f5f331 |
close the cross-member database leak with a login event trigger
The residue the last commit documented is gone. A database one member creates is now refused to every other member at connection time, so the catalogue metadata never becomes readable in the first place. Both obvious routes are dead ends, measured rather than assumed: datacl is not inherited from the template, and CREATE DATABASE fires no event trigger because it is a global object. What works is a `login` event trigger (PG17+) installed in template1 — event triggers live in a per-database catalogue and CREATE DATABASE copies the template's catalogues, so every member-created database carries it automatically. No naming convention, no sweep, no window. The first version put the function in `public` and a member defeated it in one statement: DROP FUNCTION public.officer_owner_only() CASCADE; -- takes the trigger with it They could not drop or disable the trigger itself, but in PG15+ `public` is owned by pg_database_owner — which resolves to THEM in their own database — and a schema owner may drop objects in it they do not own. Both objects were owned by postgres and it made no difference. Caught because I tried it rather than reasoned about it. Moved into a platform-owned schema with PUBLIC revoked. Every route then refused: DROP EVENT TRIGGER, ALTER .. DISABLE, DROP FUNCTION, DROP SCHEMA, ALTER SCHEMA .. OWNER TO, CREATE OR REPLACE over the top, and PGOPTIONS=-c event_triggers=off (that GUC is superuser-only). A superuser can still set it, which is the recovery path. ensureTemplateIsolation opens its own short-lived connection because a connection cannot change database and CREATE DATABASE requires no other session on the template — a pooled connection to template1 would make every member's `createdb` fail. Verified live, end to end: alice in her own, bob refused, alice refused from bob's, postgres in, owner reads and writes normally, and both members refused CONNECT on `officer`. Probe roles and databases dropped; template1 keeps the trigger, which is the intended state. Still not typechecked — node_modules is empty in this tree. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
2273941e71 |
give each member a postgres role instead of a docker daemon
A Postgres login role named the same as their Linux account, with CREATEDB, plus a
~/.pgpass so psql never prompts. This is what replaces rootless Docker: the case it
was really there for was "let me run a database to develop against", and a container
per member answered it with a daemon, an image cache and a subuid range each.
Measured on postgres:18-alpine before writing any of it, because three things I
asserted turned out to be wrong:
- a fresh LOGIN role CAN connect to `officer` (datacl NULL = PUBLIC has CONNECT),
but CANNOT read any application table — privileges are owner-only, so
has_table_privilege('users','UPDATE') is false. The capability model was never
reachable from here.
- the `trust` line in pg_hba does not cover host connections: Docker's NAT rewrites
the source, so they fall through to scram-sha-256. Verified with a wrong password.
- revoking from the ROLE does nothing. Privileges are additive and there is no DENY;
only revoking from PUBLIC is a lock.
So ensureAppDatabaseClosed revokes CONNECT+TEMPORARY on the platform's own database
from PUBLIC, and it runs inside provisionPostgresRole rather than in the setup script
— an install set up before today, or restored from a dump, then still cannot end up
with a member who can connect to `officer`.
Password is generated per member, 40 chars, rejection-sampled over an alphanumeric
alphabet: CREATE ROLE is a utility statement and cannot take a bind parameter, so the
safety comes from the alphabet rather than from escaping. Not stored anywhere — it
lives in their 600 ~/.pgpass, the same posture as their SSH key, where we keep only
the public half. Only (re)set when .pgpass is missing, so a reprovision does not
rotate a credential they may have pasted into an app config.
KNOWN RESIDUE, not handled: a database one member creates is metadata-readable by
another. datacl is not inherited from the template (measured: closing template1 and
creating from it still produced NULL), and CREATE DATABASE fires no event trigger, so
nothing can close it at creation. A second member can read table and column NAMES from
the catalogue. They cannot read a row and cannot create anything. Closing it needs a
sweep or a pg_hba rule per member; both are decisions, not details.
Verified live against the running cluster: role creation, refusal on `officer`,
creating and using two databases, and the DROP ... WITH (FORCE) teardown. All probe
roles and databases dropped afterwards.
NOT verified: bunx tsgo, still — node_modules is empty in this tree. The drizzle
return shape was checked by reading PostgresJsQueryResultHKT (RowList<T[]>, extends
Array) rather than by running it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
41cdd1e63a |
stop creating the container bind-mount directory too
~/.local/dockers existed only so a rootless container's inner uid could traverse to a bind source. No daemon, no need. Commented out with its reasoning intact in confineUserTree. .local itself stays — it is not Docker's. The claude installer targets ~/.local/bin, and the comment above it records that directory being created root-owned and blocking the install. Nothing to change in deprovisionOsAccount: it never called os-user-docker.ts. Its only Docker-shaped part is capturing /etc/subuid before userdel, which already treats a missing entry as normal and is worth keeping for any rootless tooling. Verified: transpiles. No test referenced composeDir. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
7b32f5bc2f |
don't provision rootless docker for new members
Commented out at the call site in provisionOsAccount, with the import. The code in os-user-docker.ts stays and is now unreferenced — turning it back on is uncommenting two blocks. Only affects NEW accounts. Members provisioned before this keep their daemon, and deprovisionOsAccount still tears one down, which is what those accounts need. Verified: transpiles. tsgo has still not run in this tree (node_modules is empty). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |