efa570333795d50a1559c94e34a4e4c3a65c844e
173
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
d9110c373d |
a manifest cannot ask for a particular port
A preference sounds helpful and fails silently. When the wished-for number is taken the plugin is handed a different one, the install succeeds, and whatever was configured against the old number goes on pointing at nothing - with nobody told. This afternoon that nearly happened: 3011 was only free because fofos-dev had been stopped twenty minutes earlier, and had it still been running the site would have been given a random port behind an nginx host still aimed at 3011. So the number is always drawn fresh and always kept. Always drawn means it is never something you can assume; always kept means the proxy host is configured against it exactly once. The failure mode that remains - having to go and read the number - is one that announces itself, which the other one did not. isFree went with it; it existed only to test a wish. |
||
|
|
518d329c29 |
the platform can allocate a plugin a port, and remember it
A sidecar already gets a port and nothing here changes that: it binds 0, the
kernel picks, it announces the number. That is right for what it is - a private
detail between two processes, loopback, re-chosen every restart, and nothing
outside Officer needs it.
A port something ELSE connects to is a different thing. A plugin serving a
website has to be reachable from nginx, which means the number must survive a
restart and be knowable by whoever writes the proxy configuration. Ephemeral
fails both, which is why the first site plugin was going to hardcode 3011 and
hope.
So: `extraPorts: [{ name: 'site', preferred: 3011 }]` in a manifest, allocated at
install, persisted in plugin_ports, and delivered as PORT_SITE in the sidecar's
pm2 environment. Environment rather than the registration socket because a socket
answer arrives after the process has already had to decide what to bind, and pm2
is the only thing that can tell a process something before it starts.
What the platform guarantees is the part per-plugin config could never do: no two
plugins are ever handed the same number. That is a unique index rather than a
convention, because a collision is not a duplicate row - it is two services
fighting over a listener where the loser fails to bind and the winner is whoever
started first.
`preferred` is a wish, not a claim. A plugin usually arrives after the thing that
connects to it, and moving that configuration is work the platform can spare
somebody; but a preference that could fail an install would be a claim wearing a
softer word.
Allocations survive uninstall and are released only by purge. The number is
written down where this database cannot see it, so handing it to a different
plugin because this one was briefly removed points that configuration at the
wrong service.
Pm2App gained `env`, emitted as JSON rather than hand-quoted: a port is a number
today, but this is the seam where a path arrives eventually, and one apostrophe
in a value produces a file PM2 cannot parse - which takes every process on the
machine down, not just the plugin's.
Verified: stable across calls, distinct across plugins, released cleanly,
honours a preference, and a plugin declaring no ports gets no env key at all.
|
||
|
|
19bbbab907 |
publishing a plugin is pushing a tag
Three mechanisms where there should be one. The catalogue was filled by hand, and each host separately polled twenty-one repositories with git to work out whether a release existed — a second source of truth for a fact one place already knew. Shipping photos 2026.09.03.02 correctly and watching alpha not notice for ten minutes is what started this. The timer. scripts/marketplace-cron.sh, every minute, on the machine that publishes. Safe now only because the previous commit made the sync read tags instead of working trees; over the old one this would have published somebody's afternoon on a schedule. Nothing is written unless the release commit differs from the one already published, so a tick with nothing to do is one query, one fetch per repo and no writes — about 2.7s. One timestamped summary line per tick regardless, because a fresh timestamp is the only thing that separates "nothing to publish" from "the timer died"; a named line only for a plugin that moved. The badge. `updateable` now compares the tag at HEAD with the tag the catalogue says is newest — difference, not ordering, since parsing 2026.08.27.01 works right up until somebody tags v2 and then fails silently. refreshRemote() and its two-minute window are gone with the polling they existed to paper over. The git rule stays as the fallback for a plugin the store cannot speak for: an unregistered checkout, or an unreachable store. That case now reads whatever refs the last pull left, which is the accepted cost of not making every machine poll every repository forever. fetchCatalogue()'s cache went from five minutes to one. It only drew tiles before; now the wait for a release to appear would have been the sync's minute plus its five. Also: the Update button names its destination whenever a release is known, not only on `new-release`. An untagged checkout reports `untagged` and used to lose the tag with it, so the button said "Update" and the owner had to guess where it landed — the marketplace knows, even for a checkout that has never fetched it. Verified: forced a stale row, the timer republished it inside 60s and logged one line. Badge decisions checked against the live catalogue for seven plugins, including the two shapes that matter — at a tag the store agrees with, and untagged with a release known only to the store. 9 release tests pass, 100% of release.ts covered, four of them new and all against real repositories. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
78e89ebcec |
the marketplace publishes releases, not working trees
The sync read `platform/plugins/` — the directories this machine has installed — and imported each manifest off the working tree. So publishing meant "get officer-dev's checkout to the release, then run the script", and nothing showed when that had not happened. Live proof at the time of writing: av-club 2026.08.29.01, kanban 2026.08.29.02, openseo 0.1.0 and fofos-admin 2026.08.29.26 were all in the store, and none of the four has ever been tagged. It is also why the job could not go on a timer. A cron over it published whatever was in a working tree, on a schedule. Now every registered plugin is mirrored bare into ~/.cache/officer-marketplace/mirrors and read AT ITS NEWEST TAG — manifest, README and file list all out of `git cat-file`. No working tree in the path, so publishing something uncommitted stops being a mistake anyone can make. docs/marketplace.md proposed reading manifests over Gitea's API, which needed a sandbox or a committed manifest.json. Git gives the same guarantee with neither, and the trust model is unchanged: we import our own repositories' code. Third-party submissions still need one of those two. The list of plugins now comes from the `plugins` registry table, which the docs always called the authoritative half and which was in fact a by-product of what happened to be installed. That is what lets `finances` be published without being installed here, and it spans orgs — fofos-admin lives under fofos/, which an org listing would have missed. Adding one is `--add <clone url>`. A plugin with no tag is left alone and named in the report, not deleted. It has made no release, so there is nothing true to say about it; dropping its row would be a decision about somebody's plugin dressed up as a refresh. Also: `release_tag` on plugin_metadata, because the tag is the fact when a manifest version disagrees with it; the sync warns on a mismatch. And icon URLs pin to the tag — `/raw/HEAD/` served whatever was on the default branch, via a 303, so a plugin could show one icon and ship another. Verified: 21/25 published from a release, 4 named as untagged, all 14 tag-pinned icons answer 200. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
347acf0171 |
Command HQ shows what plugins offer, and what has not been started
the UI half. a row per plugin that ships commanders, below the owner's own
categories and separated from them — because they are not the same kind of thing.
a category is the owner's filing and they may rename it; a plugin is where
something came from and they may not. mixed into one list, "av-club" reads as a
folder somebody made, and the first thing you do with a folder you made is rename
it.
offers sit ABOVE the grid rather than in it. a commander in the grid runs, has a
transcript and has cost money; an offer is a description on disk. drawn as the same
card with a badge, the two look interchangeable, and the first consequence is
somebody hunting for the transcript of a thing that has never run.
an offer STAYS after it is used. it is a template and there is no reason to have
only one — two folders watched for two formats is two commanders from one offer,
and hiding it would make the second look impossible.
the form is drawn from the template's own `inputs`. nothing in the component knows
what av-club wants: options become buttons, boolean becomes yes/no, everything else
is a field. the button names what is missing rather than marking it red.
TWO REAL BUGS FOUND BY WIRING IT UP, both in the list route:
`?category=plugin:av-club` reached `Number('plugin:av-club')` — NaN — and the query
compared `category_id = NaN`. that matches nothing and looks exactly like "this
plugin has no commanders", which is the worst way for a filter to fail. parsed
before the numeric branch now.
and the grid DROPS every row without a transcript. that was right when the only way
to get a row was to start a conversation; it is wrong now, because a commander from
a template arrives deliberately not running — so the thing somebody just created was
invisible, under a toast saying it worked. a row with a `plugin` and no session is
now projected from the row itself, with the zeroes that are true of it, and sorted
FIRST: it is the one that needs a decision. ordinary rows keep the old behaviour,
since a hand-made row with no transcript is a conversation that failed to start and
the design refuses to draw a tombstone for it.
four locale keys, all six languages, parity test green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
|
||
|
|
766b18b7f9 |
create a commander from a plugin's template, and refuse to arm a blank one
second half of the backend. GET /command-hq/plugin-commanders lists what the
installed plugins offer; POST /commanders takes {plugin, template, config} and
builds one from it.
the list is SEPARATE from GET /commanders, which returns what exists. merging a
thing that runs with a thing that could be made, distinguished by a flag, means
every consumer has to remember which it is holding.
`watch_dir` doubles as the reference path when a template supplies one. asking for
a reference path AND a watch directory is asking the same question twice and
inviting the two answers to disagree.
THREE FILES, EACH A COPY rather than a reference into the plugin:
state/config.env what the owner answered, in the form the watcher sources
watch.sh copied, so plugins:update cannot change the script a commander
is already running — a watcher whose code moves under it while
armed is a thing nobody could debug
AGENTS.md the template's prose, appended to the brief already written
config.env is generated with every value SINGLE-QUOTED, and that is not decoration:
WATCH_DIR is a path somebody typed, and this fleet has directories called `ABOUT US`
and `70´S`. unquoted, the first space ends the assignment and the watcher watches
the wrong folder — with `rm` downstream of that. verified by sourcing both that path
and one containing an apostrophe in a real shell.
THE ARM GUARD LIVES IN armCommandHqWatcher, not in a route. arming is not a button:
it happens when the agent starts a tracked background job and the platform notices.
a check anywhere else is one the agent routes around by simply starting its loop,
which is exactly what it does. a row with no template is unaffected.
verified end to end on the running server: the template listed, a commander created
from it, watch.sh + config.env (mode 600) + the brief on disk, config.env sourcing
cleanly, and the watcher refusing to start when config.env is absent — which is the
configure-before-running rule enforcing itself at the last line of defence.
still to come: the Command HQ section that shows the offers and draws the form.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
|
||
|
|
e4f5e03105 |
a plugin can offer a commander, and offering is not starting
first half: the discovery and the two columns. no routes and no UI yet. PRESENCE IS THE DECLARATION. a plugin with commanders/<name>/COMMANDER.md offers one, and manifest.ts says nothing — same as tasks/ and skills/, and for the same reason: the directory already says it, and a second place to say it is a second place for the two to disagree. read in place through capabilitySources, so only an installed AND ENABLED plugin offers anything and plugins:update changes a template without republishing. A TEMPLATE IS NOT A COMMANDER. what a plugin ships is an OFFER: nothing runs, nothing is scheduled, no directory exists until an owner creates one from it. that is the shape asked for — a plugin may propose recurring work, only a person may start it. `plugin` IS A COLUMN, not a reserved category name. provenance should be structural: a category called "av-club" is a string an owner can rename, merge or delete, and the day they do, a plugin's commanders quietly become ordinary ones with nothing on disk saying otherwise. it also survives an uninstall, which is the point — the commander is still there and the UI can say where it came from. `plugin_config` holds the owner's answers because the form has to be RE-OPENABLE. somebody changing which directory is watched should see the one it is watching, not an empty form. the watcher script is generated from this, so this is the source of truth rather than the script. inputs reuse TASK.md's vocabulary exactly, so the existing form renderer draws them and the existing validation applies. "which folder, which extensions, what output" is the same question a task asks. `missingInputs` is the configure-before-running rule, and it is checked at ARM time rather than at creation. a half-configured commander that exists and is visible is useful; an ARMED one is what would run somebody's disk against a blank setting. columns verified live after db:push: plugin text, plugin_config jsonb. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
d381140d54 |
plugins say what they are for, and their checkouts stop lying about themselves
two things, and the second is why the first was painful. TAGS. the marketplace is going to be browsed rather than scrolled: tags on the left, the plugins carrying one on the right. a plugin declares its own in the manifest and the catalogue caches it on sync, exactly as it already does for label and summary — the alternative is editing a table every time a plugin ships, and a plugin that cannot say what it is. the vocabulary is CLOSED (PLUGIN_TAGS) because free text becomes homelab, home-lab, Home Lab and selfhosted inside a month, and a filter list of synonyms is worse than no filter. an unknown tag is refused at discovery BY NAME, so the author is told which tag rather than which array index. all 21 are tagged, each with a one-line note saying why, so a later disagreement is with a stated reason rather than a mystery. every tag has at least three plugins behind it — no filter opens onto an empty page. PLUGINS:CHECK. getting one commit into 21 repositories failed seven times and none of the reasons were about the commit: six remotes were HTTPS and cannot authenticate unattended, three checkouts were behind their remote, one URL had no .git suffix so the catalogue published a clone URL unlike every other, and offscale's fetch refspec named a branch that does not exist. that last one is why this is a script and not a habit. every command reported on a phantom — `git push` said "Everything up-to-date" while the branch looked diverged — and the repository was fine the whole time. twenty minutes spent in a repo with nothing wrong with it. none of it is visible until you try to push, which is the worst moment to learn it: mid-task, across 21 repositories, and every failure looks like it is about your change. so it checks the facts with one right answer — remote URL, fetch refspec, branch, upstream, dirty tree, ahead/behind — and exits non-zero. `--fix` is a separate flag so running the check can never be what changes your tree, and divergence is never auto-repaired because it needs a decision about content. verified by breaking each case on purpose and watching it catch them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
2360a3ef60 |
agents.md holds the instructions, claude.md points at it
opencode discovers only AGENTS.md, Claude Code only CLAUDE.md — so with the content living in CLAUDE.md, every doc here was invisible to opencode. Swaps all five pairs (root, src/apps, src/databases, themes, plugins): AGENTS.md now holds the full text verbatim, CLAUDE.md is a short pointer. docs-check's permission-count regex now reads root AGENTS.md instead of CLAUDE.md; the workspace-level officer-dev/CLAUDE.md check is untouched, out of scope. |
||
|
|
52f209c69f |
caldav leaves the platform
calendar and contacts are a plugin now — plugins/caldav, its own repository like every other one. a fresh machine runs five processes instead of seven and gets a calendar by installing one plugin, which is the whole point: the platform stops carrying a python calendar server for people who may not want one. what left: the radicale sidecar, four routers, the panel apps, the dav_app_passwords table, the two screens, the two dock tiles, the permission registry entry, the settings section, and radicale from every package list. three platform things had to change first, each committed on its own: |
||
|
|
e1f5d01b90 |
a dead watch wakes its commander instead of staying dead
the machine rebooted 2026-08-24 at 12:40 and took the deploy commander's loop with it. a release was tagged the next day and sat there. the only evidence was a word missing from a card. the obvious fix is for the platform to respawn the loop, and docs/command-hq.md settled on 2026-08-18 that it must not: the agent is only notified about a process it started and is tracking, so a platform-spawned watch would run correctly, fire, and reach nobody. that trades a visible failure for an invisible one, which is worse — not watching is at least legible. so the platform notices and hands the turn back. the agent re-arms itself, the way it already does after every fire, and the process it starts is tracked. the owner's two conditions hold by construction: the commander is still the only thing that starts a watch, and the indicator still reads the pid file. DETECTION is the pid file, not watcher_armed. that column is a claim only one path sets and most commanders never take it — both commanders here read false while one was demonstrably watching. a pid file that exists with nothing alive behind it means a watch that was running and is not. which made stop ambiguous, so stopCommanderProcesses now removes the file. without that, a deliberate stop looks exactly like a crash and the reviver would ask the commander to re-arm what the owner just switched off. the commander now keeps WATCHERS.md — what each watch is, the exact command, the interval, what it does when it fires — because when the wake arrives the transcript is long past the turn that armed it and the platform deliberately does not remember the command. a commander predating the file may rebuild from JOB.md, a script in its directory, or the turn that armed it; inventing one it cannot find is forbidden, since a wrong watch looks like a working one. the owner is notified separately from the commander being woken. they are different audiences and only one of them can act when the wake does not work. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
a9dd264a72 |
a commander's children stay out of the chat list
/chat already hid commanders. it did not hid their spawned agents, and a commander that fires once an hour spawns one per run — each with its own transcript, titled after whatever its first message happened to be. one commander had thirteen sessions under its directory, twelve of them reading "Read /home/.../procedures/classify-t…". a loop's bookkeeping, filling the list you keep conversations in. listCommandHqOwnedSessionIds is the union: commanders plus their children. kept separate from listCommandHqSessionIds rather than replacing it, because a CHILD is not a commander — isCommander has to stay false for one or the composer would offer it the handoff watcher. /chat/live is deliberately untouched. it is the "what is running right now" panel, and a spawned agent that is still going belongs there — hiding it would mean an agent running with nowhere showing it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
12a9605b01 |
a commander's bound is a rule, not a list
the allow list shipped an hour ago was a snapshot. plugin permissions
register at mount, so a plugin installed after a commander was created was
missing from its list and failed as a bare 403 with nothing saying why —
and every future thing a commander is meant to do is exactly that shape:
notify a device, send mail, spawn a sub-agent, hand a transcription to an
idle one, reach a plugin that does not exist yet.
so a credential now narrows two ways, and both only subtract:
permissions an ALLOW list. exact, a snapshot. right for a credential
with one job.
excludedKinds kinds it may never reach, resolved against the LIVE
registry on every request. right for a credential whose
job is open-ended.
a commander gets the second: every kind except admin. bounded away from
installing plugins, managing users and roles and changing server settings,
forever — and open to everything the platform learns to do, with nothing
to re-issue. still intersected with the account, so for a member it
narrows itself against their role.
verified against the running server with a commander-shaped key: tasks,
notify, file-browser and the transcribe PLUGIN all reach their routes;
/api/users, /api/plugins and /api/roles are 403. the plugin case is the
one an allow list could not have passed — it was mounted long after any
key was minted.
the commander's CLAUDE.md says what it cannot do in the rule's own terms,
and that a 403 is an answer rather than a problem to route around.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
|
||
|
|
e29a4ee380 |
a credential can be narrower than its holder
a commander is an agent working unattended for months, and the only
credential in the tree lived in the MCP tool server's environment where
the agent's own shell cannot see it. so the first one built here curled
the whisper container directly on localhost: right answer, wrong door, and
a door the platform does not control.
api_keys.permissions. null is what every key a human minted still is —
their full authority, exactly what the password that made it could do. a
list narrows: the credential reaches only those permission keys, and still
only where the account itself could. intersection, never union.
checked BEFORE the owner bypass, which is the whole point. a Super Admin
consults no grants table, so a token carrying the owner's identity is
unbounded by construction — fine for a browser session somebody is sitting
in front of, not fine for a process that runs for months. this is the only
thing in the platform that can bound them. verified against the running
server: an owner key scoped to [account] gets 200 on /api/user/settings
and 403 on /api/users, /api/tasks and /api/plugins.
it propagates. a narrowed credential can write a task and run it, so the
token buildTaskApiEnv hands that script carries the same narrowing —
otherwise the script comes back through the API with full authority and
the limit bought nothing. the gate stashes it on the request for the one
kind of route that passes authority onward.
POST /tasks/:name/run: runs a script task, waits, returns code and output.
both existing runners need something watching them — the socket needs a
live connection, a job needs somebody to open Jobs. a commander has
neither. deliberately not a job: transcribing a memo takes seconds and
produces a file, and a queue plus a row plus a progress bar is a screen
nobody opened between the request and the result.
script-run.ts is the third thing this needed and the one worth having on
its own. the socket runner, the job runner and now this each carried the
same twenty lines — materialize, INPUT_ env, positional args, resolve cwd,
mint the api env — identical, with nothing saying they had to stay that
way. they differ in how they REPORT, which is real and stays with each of
them. what the script SEES is now decided once.
a commander gets its key at creation, in officer.env at 0600 inside its own
directory, and CLAUDE.md tells it where and says plainly that a sidecar
listening on localhost with no auth is still the wrong door. a file rather
than an env var because the detached loop a commander arms is spawned by
the agent and inherits nothing the platform chose. deleting the commander
revokes the row — the directory removal takes the secret, not the thing
that makes it work.
default scope is everything except `admin`: a commander that can reach
nothing gets widened in a hurry rather than thought about, and installing
plugins, managing users and changing server settings are never part of a
watcher's job. it is a default, stored per key, and for a member it
narrows itself against their role.
also fixed: POST /command-hq/commanders/new never awaited
createCommanderDirectory, so a turn could spawn before its CLAUDE.md
existed — the exact ordering that redesign was for — and the cwd it
returned was a Promise serialising as {}.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
|
||
|
|
b6d6de7e53 |
commanders: a commander is a directory, not a session
Implements phase 1 of docs/commander-directory.md. The row owns its own identity
and its own directory; session_id becomes a nullable pointer to whichever
conversation is current.
The ordering was the bug. POST /command-hq/commanders took a sessionId the caller
already held, so a chat existed first and was filed as a commander second — and
the working directory is fixed when that session spawns, before any id exists. It
now takes {name, referencePath}, creates the row, its directory and its CLAUDE.md,
and returns the cwd to start the chat in. Nothing waits on the CLI.
commander-block.ts is deleted. Claude Code reads a CLAUDE.md from the working
directory, so what it injected per turn is written once at creation and the
commander extends it as the job is agreed. resolveChatCwd no longer trusts
msg.cwd at all: it looks the row up server-side. The containment reasoning that
made a client-chosen path safe still holds — it just no longer applies, because
the client has nothing left to choose.
Four things found in review, none of which the build had.
Ownership. confineUserTree chowns commanders/ once, at provisioning, so anything
created afterwards belongs to the service user — a MEMBER's commander directory
would be one their own claude cannot write into, failing as an agent that dies on
its first command in a directory it can see. Invisible because it works perfectly
for the owner, who has no osUser. Handed over on creation, best effort, and the
error is returned as well as logged.
getCommanderDir is guarded. Two callers rm -rf what it returns, and createCommander
inserts dir_name as '' before fixing it up from the serial id — an empty name
resolves to the commanders ROOT and would take every commander the account has.
The transaction closes that window; the guard means it does not have to.
The fallback in resolveChatCwd was silent. A commander whose row is not found runs
in the account's home with no CLAUDE.md, which reads as an agent that forgot its
job rather than a lookup that missed. It says so now.
And sv-SE is renamed alongside the other five locales. It did not exist when this
work was written.
Verified: SQL applied against a scratch database first — idempotent, several
commanders can hold a null pointer at once, one-per-transcript still enforced.
Then here: db:push emits no DDL and does not prompt, officer restarts clean, and
POST /commanders returns a real directory holding state/ and a CLAUDE.md.
1428 pass, tsgo clean.
The single existing commander was deleted rather than migrated, per the design.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
|
||
|
|
c38f02afa3 |
roles phase 3: dev tools are a row, not a hardcoded name
rolePermitsDevTools() was `role === 'Developer'` — one line gating two real grants, a Postgres login role with CREATEDB and a rootless Docker daemon. A role the owner created at runtime could never have either, which defeats the point of roles being data. Two boolean columns on `roles` now carry it, and roleDevGrants() reads the row. A role that no longer exists reads as neither grant, matching "absence denies" everywhere else. NOT the `provisioningScript` D8 asked for, and that column is removed rather than left dead. A shell script stored on a role row and run at provisioning time is arbitrary root-level code in a database column, and D8's own warning says why that is worse than it looks: `usermod -aG docker` is root by a longer route. So the row CHOOSES from a vocabulary the platform already knows how to carry out — the same shape as `multiarch` on a plugin's osDependencies, for the same reason. The seed re-asserts both flags on every run for system roles, and only for them. Without that, existing rows take the column default of false the moment it lands and Developer silently loses its tools on the next deploy — every host already has these four rows, so an insert-only seed would have shipped that. `description` stays insert-only, because an edit made on one server must survive a deploy. `db:push` could not apply this and said nothing. Dropping a column while adding another makes drizzle-kit ask whether it is a rename; with no terminal it bails and the wrapper still exits 0, leaving the seed to fail against an unchanged schema. Recorded in src/databases/CLAUDE.md with the SQL, because it will hit every host on the next deploy and no agent driving a rollout has a terminal. Verified here: SQL applied guarded, Developer keeps both grants and no other role gained any, second push emits zero DDL and no prompt, 1423 pass / 0 fail. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
6e092c32ea |
marketplace: minesweeper in the catalogue
Without an entry the plugin is invisible to every server but this one — the catalogue is what a remote host reads, and the bundled constant is only the fallback when marketplace.officer.dev cannot be reached. Its git remote was the tailnet address, which marketplace-sync would have published verbatim: a clone URL that works on this network and nowhere else, failing at authentication in a way that reads as a broken plugin rather than a wrong catalogue. Repointed at gitea.officer.dev, which is the rule the sync script's own comment already spells out. Synced. 18/18, and the entry is live with the right source, permissions and six osDependencies; the repo answers an anonymous https clone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
04c1a6bfc9 |
role CRUD, with a guard the spec did not ask for
Phase 2 part 3 of docs/roles-and-teams.md. Create, rename and delete, each returning a discriminated result rather than throwing, so a router can say which rule refused and why instead of parsing an error string. Written by a Sonnet subagent against the plan; reviewed, exercised against the live database, and amended here. The amendment is the reason a review was worth doing. renameRole changes roles.name, and there is no foreign key yet — part 2 ships ON UPDATE CASCADE as its own release — so a rename would leave every holder pointing at a name that no longer exists. It is unreachable today only by accident: manage-users.ts validates against the USER_ROLES union, so a runtime-created role cannot be assigned and a non-system role has no holders. That evaporates the moment part 4 makes UserRole a plain string, and parts 2 and 4 could land in either order. So rename now refuses while anybody holds it, sharing one holder-count definition with delete so the two cannot disagree about what "in use" means. It comes out when the cascade lands, and then the cascade does the job properly by moving holders rather than refusing. Adding that reason made the router's switch non-exhaustive, which the typechecker caught — the discriminated-result shape earning its keep on its first change. System roles refuse both rename and delete: the platform's own code names them by string, so renaming one is a code change that has not happened. Delete never reassigns holders to a fallback, because that is a permission change nobody asked for. Exercised end to end: duplicate, empty name, system rename, system delete, a real rename, a rename onto an existing name, delete, and delete of something absent. The four seeded roles survive with the owner counted against Super Admin. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
44ee444cac |
the owner is an identity, not a role
Roles and teams, phase 1. `isSuperAdmin` asked `user.role === 'Super Admin'`, which was right while roles were a hardcoded union and nothing could write a new one. It stopped being right the moment roles became a table: from then on "add a role" and "become the owner" were the same feature, reachable from whatever UI manages roles. So ownership is `OWNER_USER_ID` and nothing else. `isOwner` is the new name and `isSuperAdmin` delegates to it, so no call site changes who it admits — widening any "owner-only" route to "owner or Super Admin" is a separate decision per route, not a side effect of this. Still a database read rather than a bare comparison: a deleted or blocked account must not remain the owner because its id happens to match. `getOwnerUser` becomes a primary-key lookup. It used to select on the role with `order by id` as a tiebreak, because two holders would otherwise make "who owns this server" whatever Postgres returned first — an answer that decides the agent sidecar's identity, vault access and origin scoping. That tiebreak, the write-path refusals and the CHECK all protected an answer that is now a primary key. Several Super Admins are allowed. What the role carries is real and belongs in the UI rather than a refusal here: it provisions an OS account with sudo, which puts the holder outside Officer's permission system and cannot be undone by revoking the role afterwards. That is the operator's decision, not this endpoint's. Two departures from docs/roles-and-teams.md, both recorded there. The CHECK pinning user 1's role is KEPT — redundant for ownership, but it still forbids the owner being set to Member, which is a nonsense state that costs nothing to prevent. And promotion to Super Admin is allowed while creating an account directly as one is not: promotion happens to an account somebody has looked at, creation is a form filled once, and the role that grants sudo should not be a dropdown default. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
500c2f2179 |
name the six over-long foreign keys, so a push can honestly be a no-op
Drizzle generates foreign-key names past Postgres's 63-character identifier limit. Postgres stores the truncated form, drizzle keeps comparing against its own longer one, and every `db:push` on every host drops and re-adds constraints that are already identical. Eight statements, for ever. Documented in src/databases/CLAUDE.md on 2026-08-19 and deferred as "noise rather than a fault". What ended that was last night's deploy plan saying "expect no changes, stop and report if it wants to alter something" — which this made impossible to satisfy on any host, so the deploy agent had to overrule its own instructions and say why. An annoyance had become an instruction nobody could follow. Two of the six are in this repo; the other four are in the atelier and bitcoin-wallet plugin repos and are committed there. Verified the way the doc prescribes: push twice against a scratch database, and the second run went from ten statements to two. The two that remain are `pk_music_now_playing`, and that one is tested rather than assumed. Removing its explicit name produces the identical churn under drizzle's generated name, so it is the named-composite mis-diff and not the truncation one, and a primary key cannot escape to an index form. A push now prints exactly those two lines — which is a far more useful baseline than ten, because anything else really is your change. Naming a foreign key means removing the inline `.references()` or drizzle creates both, and `walletChainCache` had to move to the three-argument `pgTable` form because there was nowhere else to put the declaration. Both noted in the doc. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
f6b9ddc877 |
a spawned session says who started it
The other half of making a spawned agent visible. The commander's pane now lists its children; opening one gave no way back — no link to the conversation that owns it, and nothing to tell it apart from a chat somebody had started themselves and forgotten. `parentCommanderOf` is the row `ingestSpawnedChildren` already writes, read from the other end. So this needed no table and no write path: a commander's children were recorded because deleting one has to take them with it, and that record answers "who is my parent" for free. Nothing had asked. `GET /chat/sessions/:id` carries `spawnedBy`, and the chat panel renders one line above the transcript linking back to the commander. Null for almost every session, which is the normal case rather than a miss — a chat somebody opened has no parent — so the bar renders nothing at all rather than saying so. Never fatal: a session must open when this lookup will not. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
7704867e05 |
roles are a table, seeded, with nothing depending on it yet
Phase 0 of moving roles out of a TypeScript union. The four the code names become rows, marked isSystem because each is referenced by name somewhere: rolePermitsDevTools tests for 'Developer', bootstrap writes 'Super Admin', users.role defaults to 'Member', and 'Admin' is what rolePermitsDevTools deliberately excludes. Renaming or deleting one of those is a code change, so the flag is there for a future delete path to refuse on rather than for the failure to surface later as an account that cannot log in. DELIBERATELY NO FOREIGN KEY on users.role. That change cannot go through db:push: push creates the table empty and adds the constraint in the same run, so every existing account violates it. Verified against a scratch database — push reported the violation, created the table, skipped the constraint, and exited 0. The FK is a separate release, once every host holds these rows. So this phase is additive and cannot fail, which is what makes it safe to hand to an unattended rollout across five hosts. Nothing reads the table yet; USER_ROLES is still the type and authorize.ts is untouched. The seed runs from postdb:push, so a host gets the rows on its next schema push with no extra step in a deploy plan. It never overwrites: a description edited on one server must not be reverted by the next deploy. Verified here the way the deploy doc now requires — by query, not by exit code. Four rows present, seed idempotent on a second run, and a second push is a no-op, which is the proof that the schema and the database agree. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
b94a5e90b8 |
SendOff — a file lands in a real directory in the recipient's own tree
Implements docs/sendoff.md's v2: a folder convention, one endpoint and a push. No staging area, no transfer table, no ack, nothing to expire. Every account already has a home, a file browser over it and a Files app on the phone, so a delivered file is an ordinary file from the moment it arrives. `Documents/SendOff/<machine>/` for something you sent yourself, and `Documents/SendOff/<sender>/` for another account's files. The home is the one that account actually browses — the owner's real login home, a member's managed home — because the two are different directories and writing to the wrong one makes a delivery invisible rather than wrong. CONSENT IS THE RECIPIENT'S, held as a row per pair. Not a role grant: a grant answers "may this role send files", and the person who should decide whether somebody may write into your home is you. Absence denies, and a sender who was not allowed does not see the recipient in their picker at all — the endpoint answers 404 for "no such account" and for "did not allow you" alike, so it cannot be used to enumerate accounts. The owner bypasses this as a SENDER only; receiving still needs their say-so. The sender's folder name comes from the token and never from the payload, so it cannot lie about who put a file there. Names are sanitised to one path segment and filenames to their basename — verified: `../../../../etc` becomes a literal folder, and a filename of `../../../../tmp/pwned.txt` writes `pwned.txt` inside the target. Collisions suffix ` (1)`; nothing is ever overwritten, because the sender cannot see the directory and would have no way to know. The push carries the folder name and a file count, and the catalogue renders "3 file(s) from andre" on the device. That relaxes "content does not travel" by a folder name and an integer — the owner's call, on the grounds that the file never leaves the tailnet and a notification nobody can act on is not worth sending. A filename would be content; whose folder it is, is addressing. `kind: 'app'` rather than `confined` even though it writes into a home: the path is one fixed shape chosen by the server from the caller's token, so there is nothing to traverse and nothing to read back. Requiring a Linux user would mean an account without one could not be sent a file, which is most of the point. Backend and verified end to end. The web send form is next. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
91bc4ac74f |
the platform keeps the commander's name
The name only ever lived in the transcript's summary record, which the harness cannot write until the first turn ends. So the list showed the opening brief until then, while the header beside it showed the name from local state, and every fix for that was a cache-invalidation trick timed against somebody else's write. Three attempts, all of them fixing the symptom. `command_hq_sessions.name` is written when the commander is filed, before the first turn produces anything, and the list and detail routes prefer it. Nullable, so rows filed before the column fall back to the transcript. The rename still happens — it is what titles the conversation everywhere OUTSIDE Command HQ — it just is not the source of truth any more. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
a51a969cd0 |
a file with no turns in it is not a conversation
Four "(untitled) · 0 msgs" rows accumulated in the sessions list from one evening of commander testing. They are transcripts containing only harness metadata — last-prompt, ai-title, atis-latch — written after a session was deleted or before it ever sent anything. Filtered at the list, not deleted on disk: the files belong to the harness, and a cleanup racing it would be the same class of bug that produced them. Which is also the cause of two of the four. The delete path resolves officer's session key from two in-memory maps, and both answer only for a session younger than the process. After a restart neither does, so no kill is sent, nothing is awaited, and the transcript is removed while the harness is still writing — which writes it back. Same failure as the events cleanup, same durable answer: the event rows carry both ids, so ask them when the maps cannot say. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
4f590f8879 |
pin both id spaces with a test that fails against the old code
Verified by reverting the fix and watching it go red on exactly the case the live delete hit — the post-restart one, where no caller can supply a session key. A regression test never seen to fail proves nothing. It touches the real database, which the rest of the suite avoids on purpose, and that is the point: this defect was never in a decision, it was in which column a `delete` matched. A test built on a fake store would have passed against the broken version and the fixed one alike. Contained by construction — every id is a synthetic uuid in a block no harness produces, nothing reads live rows, nothing deletes by anything but those ids, and each test removes what it wrote. The third case is the one that will earn its keep later: it asserts a delete leaves a NEIGHBOURING session's events alone. Resolving a key from row payloads is exactly the kind of query that starts matching too much when somebody widens it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
c04fa15eb5 |
ask the events table which key a transcript belongs to, instead of a live map
The id-space fix worked only for conversations younger than the process. The caller resolves a session key from an in-memory map filled as sessions announce themselves, so anything created before the last restart resolves to null and the key list arrives empty — which is exactly what the first live test did. Everything else cascaded; the events stayed, silently, for the one reason the code could not report. The answer was already in the rows. Every event carries `claudeSessionId` in its payload: durable, restart-proof, and the one place the two id spaces are written down beside each other. One event carrying that id resolves the key for all of that session's rows — including the majority that carry no id of their own. `sessionKeys` stays as a fast path for a live session and is now a hint rather than the answer. A cleanup that only works for conversations younger than the process is not a cleanup. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
8ac3ca7fe5 |
forgetting a conversation needs both of its ids, not one twice
`chat_session_events` is keyed on officer's SESSION KEY. The other three tables are keyed on the harness's TRANSCRIPT ID. Both are uuids for the same conversation, and `forgetChatSessions` took one list and passed it to all four — so the events delete matched nothing. Not sometimes: by construction, every time. A deleted conversation left its entire durable output behind. It survived two verifications because counting events BY TRANSCRIPT ID also returns zero by construction, and zero read as "cleaned". What settled it was reading an orphaned event instead of counting it — the payload carries both ids side by side and says outright which key the row is filed under. So the parameter is now named for its id space. That is the whole defence and it is the only one available: nothing else distinguishes the two, since both are uuids of the same shape, and a positional `string[]` cannot be got wrong loudly. Empty `sessionKeys` is legitimate and common — a session spawned by `claude -p` outside officer's websocket never had a key and never wrote an event, which is exactly a commander's children. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
cb51f35cad |
deleting a conversation forgets what the platform stored about it
Found by a sanity check after two commanders were deleted through the UI: the Command HQ tables cascaded perfectly and four OTHER tables kept a row each, for ever. 24 orphaned rows in chat_session_models, 26 in chat_session_events, against 14 live transcripts. Nothing cascades because there is nothing to cascade FROM. All four are keyed on a transcript id, and a transcript is a file the harness writes — there is no row for a conversation, so the database cannot help and the delete path has to be explicit. It was not. Three of the four are inert: a model, a bell preference, a durable copy of turn output nothing will ask for again. `nudges` is not — it is a SCHEDULE, read by the tick in nudger.ts. A nudge belonging to a deleted conversation is a timer that keeps firing at something that no longer exists. That is what turned this from untidy into worth doing now. deleteClaudeSession returns the IDS it removed rather than a boolean, because a `/clear` chain is several transcripts presenting as one conversation and forgetting the head while orphaning its parts is the same bug at a smaller scale. THE TRAP THAT CHANGE INTRODUCED, caught before shipping: every call site said `if (deleteClaudeSession(...))` and an empty array is TRUTHY in JavaScript, so a delete that found nothing would have reported success. The typecheck was clean — `string[]` is a perfectly good truthy expression. All three sites test `.length` now. Fifth silently-wrong thing today that a typecheck could not see. Cleared the existing orphans with the same function the delete path now uses, after checking the one thing that would have made it wrong: whether any of them belonged to a member, whose transcripts live in a home this scan does not read. There is one account on this machine, so they were all the owner's. Two chat_session_events rows appeared again immediately after, and are not a leak: the durable log is written independently of the transcript — that is what makes turn output survive officer being down — so a session that has just begun legitimately has events before a transcript exists. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
6d6e8a841e |
spawned children are database rows, linked to their commander
The file was doing a job it is not good at. A newline-delimited list appended by a shell cannot express a link, cannot be queried, cannot be updated in place, and cannot hold a fact that arrives later — and a child's cost arrives later, from the child, long after the loop wrote its id. So the file becomes an INBOX and command_hq_spawned becomes the record. The constraint that shapes it: the loop is bash and cannot reach Postgres. Giving it a credential so it could would put an authenticated writer inside a background job, which is worse than the problem. So the loop appends with a shell redirect and the platform ingests — idempotently, on the unique index, which is what makes ingest-on-READ safe. A cron for this would be a moving part that can stop without anybody noticing; the read is the only moment the answer is wanted. What the rows buy that the file cannot: - the link, ENFORCED. onDelete cascade means deleting a commander cannot leave an orphaned child row, which is the reason this became important. - queries. Cost per commander, how many agents a trigger spawned, which are still running. - somewhere for finishedAt, totalUsd and outcome, which the loop cannot know when it appends. totalUsd is NULLABLE and null means unknown, never zero. The children endpoint returns `unknownCost` as a count beside the total, because a commander whose children's costs are unknown must not display a confident total that happens to be too low — that understatement is the whole reason this exists. Delete ingests before reading, so a child appended since the last read is still deleted: the file is allowed to be lossy, the rows are not, but a row never ingested does not exist yet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
bd15a186b5 |
themes: rotate to the one you are wearing, star the ones you like
Four changes for 200+ themes, and one mistake worth recording. ORDERING IS A ROTATION, not a sort. The registry order is the list's only structure — families sit together in it — so lifting the selected theme to the top answers "which am I on" by destroying "what is next to it". Rotating keeps every neighbour and moves the starting point. Both lists use the same `ordered` array, which is the point: "the one below the one I have on" has to mean the same thing in the dropdown and in the ctrl+shift+space browser, or one teaches an order the other contradicts. FAVOURITES are a table, not a JSON column. theme_favorites is a row per star, so a toggle is an INSERT or a DELETE and cannot race with itself; an array in user_settings would be read-modify-written and two tabs would lose one. No foreign key to a theme, because themes are not rows — most are compiled in, the rest are directories discovered at request time — so a star can outlive its theme and the reader intersects with the live registry rather than trusting the table. Favourites-first is a second rotation-preserving pass rather than a filter, so the list can still answer "what else is there". Filtering to favourites only is a separate, explicit toggle. THE INSTRUCTIONS NO LONGER SCROLL AWAY. DropdownMenuContent carried overflow-y-auto itself, so the one line telling you the shortcuts exist was visible for half a second and then gone under two hundred entries — which is the same as not having written it. Header and footer are now siblings of a scroll box instead of passengers in it, and the header names alt+space too. THE MISTAKE: the routes went on /themes first, and that prefix is deliberately UNAUTHENTICATED — the sign-in screen paints in the chosen theme, before any account exists to ask. An anonymous GET answered 500, which was the consequence arriving politely. They now live on /theme-favorites, claimed by the `account` permission, which is core and already owns /user, /dock and /api-keys: a starred theme is the same shape as a dock preference. I then mounted it one line off, in the unprotected block beside the thing I was moving it away from, and caught that by reading rather than by testing. Verified after: /api/theme-favorites is 401 unauthenticated on both verbs, and /api/themes still answers 200 so the login screen keeps its colours. 10 tests on the pure ordering — including the property that makes a rotation a rotation: every adjacency in the original survives it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
7d7a80782e |
switch Anthropic accounts from the usage pills
The owner keeps a personal account and a company one and moves between them during a day — by what the work
is, and sometimes because one has hit a limit and the work has to carry on. /login handles ARRIVING at an
account. It could not go back to one, because the credential it overwrote was gone.
ai_provider_usage already answered "which of my accounts has room". This answers what immediately follows:
"then use that one". The chips under the numbers stop selecting whose stats you read and start selecting who
you ARE — which turned out to be the useful half of the same gesture, since reading the other account's
percentages was only ever a prelude to using it.
anthropic_accounts holds each login's OAuth object encrypted, keyed (user_id, account_uuid).
One key per ACCOUNT, not one for the table: encryptSecret('anthropic-account:<uuid>', …), so the secret
store mints a separate key each and one leak is one account. Same reasoning that split VAULT_STORE_KEY into
purposes. The uuid comes from Anthropic and survives a re-login; the email is stable too but is editable at
the provider.
What is stored includes a REFRESH token, deliberately — the access token lives hours and the refresh token
is what makes a switch work a week later. So a row plus its key is thirty days of acting as that account.
That was accepted knowingly; the per-account key narrows it, and refreshExpiresAt is a column so a dead
credential gets purged on read rather than sitting here for ever. An expired one cannot be switched to at
all: reveal returns null, because writing an expired credential would sign the owner out with no message.
Capture happens on login AND on every read of the account list. Without that second one this would know
only about logins that went through this dialog, and an account signed into from a terminal would be
invisible to the switcher — half your accounts missing, silently. The switch also captures the OUTGOING
credential first, or switching away from a terminal login loses it.
.credentials.json is replaced wholesale; .claude.json is PATCHED. The second holds the CLI's entire config
and overwriting it would take out every project entry and preference in it.
Owner only, and the isolation is unchanged: getOwnerHomeDir ignores the email it is passed, so every path
here is the owner's. A member's claude reads their own home and none of this touches it. The earlier claim
in this work that the credential was "machine-wide" was wrong — the owner corrected it. What a switch
actually changes is every claude running AS THE OWNER: terminals, task runners, pipelines, the commander.
The spinner is not decoration. A switch rewrites a file and changes nothing visible, so without it the
honest reading of a click is "nothing happened" and the second click is somebody checking. A running turn
keeps the credential it was spawned with, and the response says so rather than leaving the UI to imply
otherwise.
Verified before shipping: capture records the live account with org, plan and expiry; reveal decrypts
byte-identical to what is on disk; /api/claude-auth/accounts is 401 unauthenticated and 403 for a non-owner.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
43aeffd9d3 |
api keys: revoked ones stop filling the screen, and can be forgotten
Five revoked keys, no live ones, and no way to remove any of them — which is what the screen looks like after a few rounds of testing. `listApiKeys` returns every row and nothing filtered them, so a list whose job is "which of my keys still work" answered with a wall of dead ones. Nobody decided that. Revoke was implemented and the list was never taught the difference. Dead keys — revoked or past expiry — are now hidden behind a "Show 5 revoked or expired keys" toggle rather than merely dimmed, because on any account in use for a while they outnumber the live ones. The empty state distinguishes "No keys yet" from "No keys in use": the first is false on an account whose keys are all revoked, and it is exactly the sentence that sends somebody hunting for a bug in the list. DELETE /:id/record removes the row for good, and only after it is revoked. The `revokedAt is not null` clause is in the statement rather than checked before it, so there is no ordering in which a live key is deleted by a request that raced a revoke. Nothing is weakened by allowing it: the key cannot be honoured again either way, since authentication matches a hash and a deleted row has none. What goes is the history — `lastUsedAt`, the only evidence of whether a leaked key was used before it was killed — which is why it stays a second, deliberate step rather than what revoke does. Also corrected the copy, which claimed keys are per-app. They are not scoped to anything: one key works everywhere, and per-device is a revocation convenience, not a requirement. The owner had to ask. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
e37b1a151c |
usernames are required, and unique case-insensitively
two platform fixes that make a plugin's job smaller, both from the owner: downstream services identify a person by name, so the platform has to be the one holding that name to a single meaning. `getUserByUsername` compared with `eq`, so `diogo` and `DIOGO` were two accounts. create-user.ts asks "is this taken?" through that function, which means the check could not see its own collision. it compares on `lower()` now. and the constraint is on `lower(username)`, not the column — a plain unique index is what allowed both to exist. it sits in the schema rather than only in create-user.ts so a second code path, a migration, or someone at a psql prompt cannot get around it. the column is NOT NULL. `validateUsername` has always demanded one, but the column allowed null, so anything skipping validation could write a user nobody could name — and a Linux account or a Jellyfin login then has nothing to work with. both databases were checked before the change: zero null usernames, zero case-collisions on either, so no migration was needed. proved rather than assumed, inside a transaction that was rolled back on the live database: `PROBEUSER` refused against `probeuser` by uq_users_username_lower, and a null username refused by the not-null. no rows written. this also removes a case the jellyfin reconcile had to carry: an officer account with no username could not be matched or created there, because jellyfin authenticates by name and has no email login. |
||
|
|
a6e61d0c26 |
user provisioning: the platform pushes the account list to plugins that declare it
a plugin that creates accounts in an upstream service — jellyfin first, immich next — needs to know who the platform's users are. it now gets told. DECLARED IN THE MANIFEST, not asserted at registration. `name` and `handles` arrive on an unauthenticated socket and are believed, so a capability claimed there would be a plugin granting itself one. the manifest was reviewed when the owner installed it, and sits beside `permissions` where "this plugin will be told who your users are" belongs — on the install screen, not discovered later. PUSHED, not requested. a sidecar has no authenticated way to ask, so a request could only be answered, never authorised. a push inverts that: the platform decides who gets it, and a caller registering under a name with no such manifest gets nothing. `listProvisionableUsers` is a hand-written projection — id, email, username, name, role — and NOT `select *`. `users` holds `password` and `os_user`, and `getUsers()` returns both, so a plugin reaching for the obvious function would get every account's password hash. naming the columns means adding a secret to the table cannot silently add it to what plugins receive. pinned by a test that asserts the key set, not just the absence of today's secrets. the owner is excluded. they bypass every permission check and already have whatever upstream account they want; provisioning them is at best a no-op and at worst a second admin nobody asked for. sent after the ack rather than inside it, so registration never waits on a database query — a slow postgres would otherwise stall every sidecar's startup, and none of them need the list to start. and re-sent on every account create and delete, because the registration copy is a snapshot: a plugin still holding a deleted user would keep an upstream login alive for someone the platform has removed. read once per broadcast, so two plugins cannot be told different things about the same moment. |
||
|
|
18bfff5e74 |
nudger: the due query was invalid sql, and failed silently every tick
`dueNudges` passed a JS Date as a parameter on the left of an interval subtraction. postgres will not infer a type there and refused the whole statement, so the tick threw once a minute and no nudge could ever fire. the symptom was the worst kind: the row sat in the table enabled, sent_count 0, last_fired_at null — indistinguishable from a schedule that was armed and simply had not come due yet. it was found by checking the row after arming it rather than by trusting the log line that said "armed". `now()` in sql instead, which also means the comparison uses the database's clock rather than the platform's. the test asserts only that the query EXECUTES. what broke was the sql being invalid, and no amount of reasoning about interval arithmetic would have caught that — only running it against a real postgres does. |
||
|
|
12c5e51ce4 |
nudger: a schedule that keeps a chat moving when nobody is watching
an agent told to record blockers and continue still stops at the end of a turn, and at 3am nobody types the next message. this is the schedule that does. the door already existed. `startClaudeTurn` takes `ws: null` — extracted that way so command-hq could drive a turn over http — so this is a second caller of an existing path rather than a new way into a live session. i had said the platform lacked that; it did not. the rule that decides the design: never speak while the agent is working. so `every_seconds` is a FLOOR, not a metronome. when a nudge comes due and the session is generating, the row is left alone and reconsidered next tick, which means it fires as soon as the turn ends rather than sleeping through to the next hour. a strict alarm would skip an agent that finished four minutes late and leave it idle for the other fifty-six. `isGenerating` unknown counts as BUSY. a sidecar that cannot answer might be mid-turn, and interrupting one is the failure this exists to avoid. matched on both sessionKey and claudeSessionId: officer's handle and the transcript's filename are different ids, only the agent holds the mapping, and a schedule stored against one would silently never match a live row keyed by the other. one nudge per (user, session) — a second schedule for one conversation is two alarms for one sleeper. counted even when the turn fails to start, so a refusal cannot become a tight loop. disables itself at the ceiling rather than leaving a spent row armed. the tick starts in server.tsx rather than on first use, because a schedule armed before a restart has to survive one. |
||
|
|
46d621c91b |
chat notifications reach the phone, on the same rule as the bell
the browser bell and phone push are now two subscribers to one signal. both
ask shouldNotify(); neither owns the rule.
deliberately a second subscriber rather than a branch inside notify-delivery.
the two channels share only the question "does this account want to hear about
this" — the browser needs an open tab and sends the chat's real title over a
loopback socket on the owner's own tailnet, the phone needs no tab and must
send NO title, because that hop goes through apple. one function doing both
means an `if (isPush)` at every step, and the first thing to go wrong is a
title leaking because the branch was missed in one place.
two codes, officer/core:chat.turn-ended and chat.input-needed. the route is
written out with its scheme rather than derived from the target: the hub has
no chat screen — chat is Butler, a sibling app — so these deep-link OUT over
officer-chat://, and two of the suite's twenty-one schemes already break the
officer-<app> pattern (officer-videos, officer-readaloud). deriving that works
until it silently does not.
`thread` interpolates now, so `chat:{id}` groups per conversation. a static
thread forces a choice between "three pings from one chat collapse" and "two
busy chats stay apart", and the wrong answer is that every chat is the same
chat.
dropped officer/core:agent: chat.turn-ended supersedes it and its route
pointed at officer://chat/{id}, a hub screen that does not exist.
the banner cannot name which conversation. a chat's title is content and
content does not travel; it says what happened, and tapping opens the chat,
which is where the name lives.
|
||
|
|
72d327e360 |
bell: scope the preference to a user, and make every_turn mean every turn
review of
|
||
|
|
5cba1b65a6 |
notify: only "the app is gone" deletes a device row
reported from the field on the first real push: it failed with BadDeviceToken and the server deleted the registration. the build was production-signed and the row said sandbox — a configuration mismatch, with the device and the app both perfectly fine. pruning there destroys the evidence. the phone re-registers on next launch, the row comes back looking healthy, and the next send fails identically; you can lose a day to that loop without ever seeing what is actually wrong. so the one set becomes three. Unregistered/UNREGISTERED/NOT_FOUND prune — apple and google are saying the app is uninstalled. BadDeviceToken, DeviceTokenNotForTopic, TopicDisallowed, INVALID_ARGUMENT and SENDER_ID_MISMATCH are OURS: the row is kept and the log says what to check. everything else is transient and still counts toward three strikes. INVALID_ARGUMENT was the sharpest one. it is how fcm reports a malformed MESSAGE — including a non-string in `data`, which is the mistake this codebase is most likely to make and already has a test for. deleting on it meant one bug in the payload builder would wipe every android device from the registry on a single send, presenting as "android push stopped working" with nothing left to explain why. misconfigured is also not counted as a strike. not deleting is not enough on its own — a persistent mismatch would reach three failures and delete the row anyway, which is the same bug with a slower fuse. the apns log now names the mismatch rather than the reason code, since the code alone sends people to look at the device: it prints the row's environment and topic and says to check the SHIPPED artefact rather than the intermediate build, which is exactly where this one hid. |
||
|
|
16369f3570 |
secret store holds foreign secrets, and notify reads from it
putSecret/getSecret, `bun run secret:set`, and notify taking its credentials from the store with env as the fallback. this was described two messages ago as a later thing; it is about fifteen lines and it was blocking the honest answer to "where does the .p8 go", so it is done. getKey was the wrong door for anything supplied from outside — it MINTS a random 32-byte value when the purpose is absent, which for a foreign credential means silently authenticating to apple with a fake. getSecret returns null instead, which is what lets the env fallback work. putSecret retires the previous row rather than deleting it: a mistyped paste over a working key should be recoverable, and retiredKeys already exists for that shape. one transaction, because the partial unique index means the retire has to land before the insert. the CLI reads stdin and has deliberately no --value flag: a secret in argv is in shell history, in ps output, and in any audit log that records command lines. offering the flag would make the convenient path the leaky one. it prints a length and the last four characters, never the value. also `bun run notify:test`, which fires officer/core:test at a registered device. the sidecar's port is ephemeral, so it now writes its address to data/notify-server.url on boot and removes it on clean shutdown; the CLI treats an unreachable address as "not running" rather than trusting the file, because a crash leaves one behind. the script says what actually happened, because the day-waster here is that a send with nothing configured returns success and delivers nothing — it refuses up front when channels is empty, and calls out sent:0/failed:0 as "no device registered" rather than letting it read as success. verified end to end with a p-256 key generated locally and then deleted from the store: zero APNS vars in .env, sidecar reported channels: apns, and the send reached the channel. store is back to jwt and offscale only. |
||
|
|
fd7c9becb6 |
notify is core again
built 2026-07-31, switched off 2026-08-13 when it was slated to become a plugin alongside memos and photos. those two were extracted; this one never was, so it spent six days mounted nowhere with its schema commented out and no pm2 entry — complete code that nothing could reach. core rather than a plugin because the producers are the platform's own: the job queue, an agent turn ending, mail arriving. a notifier the owner can uninstall is one those producers cannot rely on. - hono.ts mounts /api/notify again; verified 401 unauthenticated, so the permission gate covers it and assertPermissionTotality accepts the mount - push_devices exported from the core schema and pushed: 10 columns, uq_push_devices_token_bundle, idx_push_devices_user - officer-notify added to CORE_PROCESSES, so gen:ecosystem writes it - credentials read OFFICERDEV_X ?? X, the convention it predates safe as core because it boots with nothing configured: every channel reports isConfigured() false and dispatch resolves empty. verified by importing it with the env stripped — "channels: none configured", no throw. an install with no apns key runs it as a no-op rather than crash-looping. APNS_ENV is dropped from the doc's credential table. it was never read: the host comes from each device's `environment` column, so one server pushes to a testflight build and a release build at once. doc status corrected. the app side is not started — there is no officer-suite repo, no PUSH_NOTIFICATIONS_APP.md, and apps/chat actively strips the ios push entitlement. recorded there rather than here. |
||
|
|
0fd89082fe |
an uninstalled plugin's grants stop breaking the permissions screen
Uninstalling music left its grants in role_permissions. The keys stop existing when the plugin unmounts, so the settings page — which loads every grant and submits them all back — failed with "Unknown permission 'music'" for a row nobody could see, edit or remove through the UI. Nothing could be saved again without editing the database. Two changes, because either alone leaves half the problem. Uninstall now revokes the plugin's grants, across every role rather than one. A deliberate exception to "uninstall deletes nothing": a grant is the PLATFORM's record of who may reach the plugin, not anything the plugin stored. Reinstalling means granting again, which is the honest behaviour — the alternative is access silently returning. And the write DROPS an unknown key instead of refusing the request, so every server already in this state is unblocked without touching its database. That is what the resolver already does on read, so the write now agrees with it. The cost is that a typo in a hand-made API call is ignored rather than reported — worth less than a settings page that cannot be used. The response returns what was dropped, so it is not silent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
b9524ee4d5 |
prefix the env with OFFICERDEV_, and keep reading the bare names
PORT, PUBLIC_URL and POSTGRES_URL are names half the software in the world uses. Bun auto-loads .env into EVERY process started in the platform directory, so a bare PORT reaches the sidecars, the tools they spawn and anything an agent runs from here — and a bare POSTGRES_URL reaches things with no business holding a database credential. Four parts. officer-setup now writes OFFICERDEV_-prefixed names, with the reasoning in the generated file so whoever opens it later knows why. Every consumer reads `OFFICERDEV_X ?? X` — eleven sites across the databases, the DAV router, officer-url.mjs, os-user-postgres and two scripts. The bare name is the FALLBACK, never the source, which is what makes this safe to ship before every machine has been converted. `bun run fix:env` converts a file in place. Idempotent, preserves comments and `export`, leaves the value untouched including an `=` inside it, and refuses to touch names belonging to other software — prefixing PATH or NODE_ENV does not rename a variable, it deletes it. And it runs on every restart from the server settings screen. There rather than at boot because a restart is the one moment the file can be rewritten and take effect in the same breath: the process about to start is the one that reads it. Non-fatal, since the fallback means the worst case is that nothing changed. gen-index reads either spelling out of the file directly, because it is run by hand as often as by the setup and a bare shell has no .env loaded. Verified by converting this machine's own .env: PORT, PUBLIC_URL and the database all still resolve, gen:index still finds its URL, 1152 pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
8f15f16d1c |
restore dav_app_passwords to the db:push barrel
Missed when calendar and contacts became core in
|
||
|
|
bbb16d6ed3 |
bitcoin-wallet, extracted into plugins/bitcoin-wallet — the platform half
Tenth extraction, and the first where the blocker was a permission rather than a path. The wallet was kind: 'admin' — owner only, never grantable — and plugin permissions are always kind: 'app'. mount.ts forces that and the manifest has no field for anything else, deliberately, so a plugin cannot ask to be restricted. Extraction therefore could not preserve admin. Raised before moving any code, because turning "the server owner's bitcoin" into a row in the grants list beside Photos and Music is the kind of change nobody notices until someone ticks it. Made anyway, and the objection inverts: every wallet query is scoped by user_id, taken from X-Officer-User which the platform injects from the session, so granting the permission gives an account its OWN wallet rather than access to anyone else's — including to the owner, whose bypass covers permission checks and not query scoping. kind: 'admin' was single-user thinking. Named bitcoin-wallet, not wallet: appName drives route, API prefix, table prefix and install key together, so the name should say what it is. Tables are bitcoin_wallet_*; the prefix guard normalises the hyphen. Verified free before committing to it — no wallet table exists on any server, and db:push would treat a rename as a drop plus a create. registry.test.ts re-anchored: `wallet` was its canonical kind: 'admin' fixture and its no-selfService fixture. Now user-admin/server-admin and terminal, with a note that a plugin's permissions are always `app` — which is exactly why this one needed a decision rather than a default. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
f566fda0b2 |
invoices, extracted into plugins/invoices — the platform half
Ninth extraction, third resurrection. Switched off since 2026-08-13 with the permission reduced to api: [], so assertPermissionTotality was satisfied, the server booted clean, and every panel request 404'd. Closes the same data hazard as jellyfin and soulseek: invoiceshelf/schema was commented out of the drizzle barrel. Named `invoices`, not `invoiceshelf`. The appName is THE id — API prefix, frontend route, table prefix, sidecar suffix, install key — and before extraction the API (/api/invoiceshelf) and the screen (/invoices) had drifted apart, which was harmless only while they were separate hardcoded strings. The route is the part a person sees, and the permission key and label were already `invoices`, so the API prefix was the odd one out. Cost three call sites and a table rename to invoices_accounts, which was free only because the table existed on no server — verified before deciding, not after. members.test.ts re-anchored again, this time to caldav and memos: services with no extraction planned. That assertion has now been re-pointed four times by services leaving for plugins/, which is the whole reason installer.test.ts was made literal earlier tonight. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
aebd6ff0c0 |
jellyfin, extracted into plugins/jellyfin — the platform half
Eighth extraction, second resurrection. Same shape as soulseek: hono.ts had the router import and its PROTECTED_MOUNTS entry commented out since 2026-08-13 and the permission reduced to api: [], so assertPermissionTotality was satisfied, the server booted clean, and every panel request 404'd. Also closes the same data hazard — jellyfin_servers was absent from the drizzle barrel, so bun db:push would have dropped it. No frontend call site needed repointing: mountPrefix derives /jellyfin from the appName, which is what the router already hardcoded. Test fixtures fixed properly rather than re-anchored again. Extracting jellyfin broke 18 tests, the FOURTH extraction in a row to break the same files — photos and transmission yesterday, slskd an hour ago, jellyfin now. installer.test.ts now builds literal CatalogueEntry fixtures and asks the catalogue nothing: it pins planSteps and runInstall, which are installer logic, and a service leaving for plugins/ says nothing about whether the installer still plans the right steps. catalogue.test.ts stays the place real entries are checked well-formed. members/catalogue/preflight re-anchored to memos, which has jellyfin's exact shape. The .prettierignore rule for the vendored hls.mjs moved into the plugin rather than being left behind naming a deleted path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
b196c9cb55 |
soulseek, extracted into plugins/soulseek — the platform half
Seventh extraction, and the first of something that was already dead. Soulseek had been switched off since 2026-08-13: hono.ts carried the router import and its PROTECTED_MOUNTS entry commented out, so createSidecarProxy never ran and registerProxiedPrefix was never called; officer-slskd was not in the ecosystem at all. Because the permission entry had been reduced to api: [], the totality check was satisfied and the server booted clean — the UI rendered and every request 404'd. Worth naming: the boot check proves every MOUNTED prefix has a permission, and is silent about a feature that mounts nothing. Also closes a live data hazard: schema.ts had the soulseek export commented out, so the three tables were absent from the drizzle barrel and bun db:push would have dropped soulseek_favorites, soulseek_browse_snapshots and soulseek_browse_dirs on any server holding them. rebuild-soulseek-tree.ts documented the workaround rather than the problem; deleted with the rest. The connection stays in core service_connections under service 'slskd' while the mount is now /api/soulseek. Those differ on purpose. Test fixtures: slskd was canonical in three app-store tests, the third extraction running to break them. installer.test.ts now builds a literal fixture instead of reaching into the catalogue — it pins planSteps, which is installer logic, and no catalogue entry is both provisioned and members:'none' any more. catalogue.test.ts and members.test.ts re-anchored to vnc/notify/gitea. Unrelated but mine: check:theme caught 5 palette classes I introduced in CommandHQ earlier tonight against a baseline of 0. Converted to bg-success / bg-warning. check:theme is not part of bun test, which is why the green suite never showed it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
a327dfc82c |
photos, extracted into plugins/photos — the platform half
Fifth extraction, and the clean case: no reverse dependency, configuration already server-side, frontend already panels. Everything photos now lives at gitea.officer.dev/plugins/photos. Deleted, not commented: the sidecar, the api router, the db directory, the Photos app (25 files), the screen, the App.tsx route pair, the Dashboard barrel line, the usePageTitle rule, the AppRegistry wiring, the officerdev barrel re-exports, the permission registry entry, both database barrels, the hono.ts import comment, and the app-store catalogue entry. The catalogue deletion is required rather than tidying — the trap the runbook records as having bitten twice. permissionAvailability() derives from sidecar_installs, which a plugin never gets a row in, so a leftover entry makes the permission permanently 'unavailable' and withholds the route and dock tile on a server where the plugin is installed and working. ── Test fixtures, repointed rather than deleted ── Photos was the canonical app-store fixture across four test files, chosen for being existing|provisioned with members: 'accounts'. Jellyfin has the identical shape, so the assertions moved there — they are about the installer, not about photos, and deleting them would have quietly dropped coverage of the provisioned install path along with the plugin. One loss worth naming: Immich's `provisioned` install mode went with the catalogue entry. Docker provisioning for plugins does not exist yet, so photos is point-at-your-own-instance until it does. bunx tsgo clean. 1080 pass / 0 fail, unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |