two holes found auditing the wallet sidecar after the frozen-utxo fix.
the brute-force backoff lived inside UnlockSession.unlock alone, so /unlock capped
at five guesses a minute while export-seed — the one endpoint that returns the words
in the clear — took unlimited ones. every passphrase check now goes through the same
guard. verifyPassphrase rethrows LOCKED_OUT rather than folding it into `false`, so a
caller can tell "wrong" from "stop".
nextUnused advanced its mark on every issuance, paid or not, so a run of unpaid
addresses walked it past the end of the window the next scan covers; a payment there
would never be found again, and esplora has no rescan to go looking. sources now
declare how far past a scan's last index they can still see, and issuance clamps to
it — re-offering a virgin address rather than handing out one that could lose money.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
freezing a coin promised it would not be spent, and the promise only
held when the caller named outpoints explicitly. an ordinary send picked
its own inputs from a snapshot in which every utxo said frozen: false —
the chain has no idea what officer froze — so selectCoins, which has
always filtered on that flag, never saw one set. sendAll was the worst
case: "send everything" swept the frozen coin too.
the frozen list now travels with the request, set by the route and
overwritten if a caller supplies one. it can only ever restrict what is
spendable, so smuggling a value in gains nothing. the backend still
reads no officer table.
three tests pin it, including a control that sends successfully once the
coin is unfrozen — without it the other two would pass on a wallet that
could not spend at all.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
the route typed its body as {name, defaultBip, config} and passed the
parsed object straight to updateWallet, which also accepted sealedSeed
for the passphrase change. a TypeScript annotation strips nothing at
runtime, so any authenticated caller could send a sealedSeed key and
overwrite the encrypted seed — no passphrase, no unlock. encryptSecret
encrypts nonsense happily, so the damage would have surfaced at the next
unlock, not at the write.
updateWallet can no longer touch the seed at all; resealing moves to
replaceSealedSeed, whose only caller has already proved knowledge of the
old passphrase. the route rebuilds its patch field by field as well, so
the next field added there cannot re-open it.
verified against the test wallet: the envelope is byte-identical after
the same request that previously would have replaced it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
the card keyed off `sync.rescan` being null, which is what esplora
reports — and equally what nbxplorer reports before its first scan. so
the guard could not tell "unsupported" from "not yet run" and resolved
it the wrong way: every esplora wallet got a button that 501s.
canRescan carries the distinction explicitly.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
registering an xpub only indexes it from that moment on, so an imported
seed with history read as a confident zero: every call succeeded, the
coins were simply absent. scantxoutset walks the node's current utxo set
directly and finds them regardless of when the account was registered.
runs all four script variants sequentially — the funds could be on any
one — and surfaces progress through the existing SyncState channel so
the balance says "scanning" rather than nothing. auto-fires on an
imported mnemonic only; a generated seed has no history to look for.
recovers spendable coins, not spent history.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Background tasks already had a row in the transcript, but a row scrolls away — and a
task started ten minutes ago is precisely the one you want to keep an eye on. The same
`role: 'task'` rows now also drive a tray docked above the chat input: a chip per task,
running ones pulsing, finished ones dismissable.
Clicking a chip opens what the task is actually doing right now. Nothing about that
crosses the wire between `task:started` and the notification, so it is read from the
file Claude Code streams the task into:
$TMPDIR/claude-<uid>/<project-slug>/<session-uuid>/tasks/<task-id>.output
For a backgrounded shell that file IS the log; for an agent it is a symlink to the
subagent's own transcript, which is ordinary session JSONL and so parses with the
reader we already had. Both kinds are therefore reachable from one directory.
Resolution is by task id alone, deliberately: the client learns a task id from
`task:started` and nothing else — officer's per-connection session key is not Claude's
session uuid, and the uuid only arrives with the turn result, long after the tray needs
to show the task. A task that has not written anything yet answers 200 `{kind:'pending'}`
rather than 404, because that is the ordinary first second of a task's life.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
it said fourteen; there are nineteen. photos, notify, caldav, memos and jellyfin have landed
since it was last written. also names the two mybiblepal entries that share this pm2 daemon
and belong to a different project, and points at `pm2 jlist` as the source of truth so the
next reader does not trust a list that goes stale every time a sidecar ships.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nothing the player did ever reached jellyfin, so nothing ever appeared in continue watching
or in the currently-playing list. the proxy was fine — a progress POST through the sidecar
moves the resume point upstream and answers 204. the client was the problem.
useClient() rebuilds its verbs on every render, so `report` had a new identity every render,
so `sendReport` did, so the effect whose cleanup reports the final position re-ran on every
render — and that cleanup nulls planRef. planRef went null a few milliseconds after the
stream opened and every report after that returned early. the ten-second heartbeat never
fired either: its interval was cleared and restarted on every render, and timeupdate renders
about four times a second.
both are now held in refs, and the two effects have honest dependency lists. same treatment
for the negotiate mutation, which react query also rebuilds per render.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
the sidecar now describes every audio and subtitle stream on the chosen source. a subtitle
jellyfin can deliver as an external file gets a /_jf webvtt path with the embedded ApiKey
stripped; one it cannot gets the reason instead, so the picker can show it disabled rather
than pretend it does not exist. colliding labels get their stream index — two tracks called
"English - ASS" are a coin flip otherwise.
quality is a named rung rather than a number. a bitrate the owner picks maps onto the rung
at or below it, which is what stops jellyfin's ResolutionNormalizer from inventing a
resolution from an unrounded number. auto stays absent so a copyable source still copies.
the control bar is ours in all three transports. progressive has no timeline the browser can
render, and an audio-track or quality change is a re-negotiation with the server rather than
something a <video> knows how to do — one bar for all three is what keeps those from being
three different players. the menus are hand-rolled because a radix dropdown portals to
document.body, which is outside the fullscreen element and would be invisible exactly when
the player is most likely to be used.
no scrubber thumbnails: they come from trickplay tiles and this server generates none.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
a live transcode has no length and no byte ranges, so the progressive mp4 the player
used could not be seeked — a scrub restarted ffmpeg at a new offset. jellyfin's own
HLS playlist is VOD and spans the whole runtime, so seeking it is a segment request.
hls.js is checked in rather than installed. installs are frozen so that adding a
package is a reviewed act, and a committed file also has no install-time hook, which
is the vector the 2026-08-04 npm worm used. provenance, hashes and the update recipe
are in vendor/README.md; the tarball sha512 matches the registry's published integrity.
the sidecar now overrides VideoBitrate and MaxWidth on the TranscodingUrl jellyfin
hands back, for the same reason progressivePath computes them: jellyfin resolves that
bitrate from MaxStreamingBitrate (~119 Mbit, the ceiling that exists to let a stream
copy through) and sets no width, which asks a CPU-only container to encode 4K.
safari is deliberately not given the m3u8 — segment URIs are relative and would not
carry the ?token=, and it cannot set an Authorization header the way hls.js can.
progressive stays as the fallback for any browser without MSE.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
a transcode url without `videoBitrate` is not "let the server choose".
jellyfin resolves the missing value to 0 and runs ResolutionNormalizer
over it, which maps a bitrate onto a resolution — 0 lands on the bottom
rung. verified here: a 3840x1600 hdr source came back through
scale=...min(max(iw,ih*a),416)... with -maxrate 0.
the number has to be picked per source, because the two cases pull
opposite ways: a cap is a reason for jellyfin to REFUSE a stream copy,
and the absence of one is what makes a cpu-only 4k encode hopeless. so
copyable h264 asks for a ceiling nothing hits and no width, and anything
being genuinely re-encoded asks for 1080p at 12 mbit.
verified both branches against the ffmpeg command jellyfin logs: h264 mkv
→ -codec:v:0 copy, hevc 10-bit hdr → libx264 at 1920 wide, tonemapped.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
adds the /jellyfin screen and its two panels (nav + view) on top of the
jellyfin sidecar, following the photos/invoiceshelf shape.
the transcode path is a progressive mp4 rather than hls, so no hls.js
dependency is added under the frozen-install rule. that stream has no
length and no byte ranges, so the player owns its own scrubber and seeks
by re-negotiating at a new startTimeTicks, tracking offset + currentTime;
a direct file keeps native controls. the hls url is still returned, so
switching later is a player change only.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
officer-jellyfin owns the whole Jellyfin contract: the instance URL, the access
token, the Jellyfin user it belongs to and the DeviceId its sessions are keyed
by. The platform side is a 17-line proxy holding no credentials.
Servers are a registry, not a single row — this machine runs four instances and
the owner switches between them. The password is never stored: it is traded once
for an access token through AuthenticateByName, and only that token is persisted,
encrypted.
Two doors. /_officer/* is a hand-written JSON façade for the things the browser
should not have to know — the user id in the path, the Fields lists that decide
whether a grid has posters, the PlaybackInfo negotiation. /_jf/* is a GET-only,
allow-listed byte pass-through for images, video, HLS and subtitles; it keeps
Jellyfin's own paths because a master playlist references its segments
relatively, so any renaming would mean rewriting m3u8 bodies.
TranscodingUrl arrives with api_key=<access token> in its query string and would
otherwise be handed straight to a video element. It is stripped before anything
is returned; the pass-through re-adds the credential as a header.
Video only — Officer's own player owns audio, so music collections are filtered
out of the library list.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
bunfig.toml sets [install] frozenLockfile = true, so `bun install` resolves from
bun.lock and nothing else — it fails rather than quietly picking up a newer
version, including a transitive one nobody chose.
Config rather than a documented habit, because a supply-chain compromise does not
wait for the one time somebody forgets a flag. On 2026-08-04 eleven cache
packages — keyv, flat-cache, file-entry-cache, cacheable-request, cache-manager,
the @cacheable/* scope and ecto — were published with a preinstall dropper that
harvested npm and GitHub tokens, AWS and Kubernetes credentials, SSH and PEM
keys, .env files and .claude/settings.json, then republished itself through any
npm token it found, reaching 434 further packages across 1381 versions. This
machine was unaffected only because nothing had installed since 2026-08-02.
Verified on bun 1.3.10 rather than assumed, and the first result was wrong: with
NO lockfile present, neither the flag nor the config refuses — bun simply creates
one, so an initial test made the setting look ignored. Against a lockfile that no
longer satisfies package.json, both exit 1 with "lockfile had changes, but
lockfile is frozen". The config is honoured; the earlier reading was a bad test.
`bun install` in this repo still reports 1058 installs, no changes.
Applied to monorepo-mobile too, where flat-cache, file-entry-cache and keyv are
present as eslint/got transitive deps — at versions old enough to be unaffected,
which was luck rather than design.
Documented in platform/CLAUDE.md and the workspace root, including the part that
matters most: do NOT add --no-frozen-lockfile to a script, a Dockerfile or CI to
make the error go away. The error means the lockfile and package.json disagree,
and an unexplained lockfile change in a diff is precisely the signal this exists
to produce.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
99.97% printed as "100.0%" beside a still-blue bar, which reads as a stuck
torrent. only a genuinely complete fraction shows a hundred now, as "100%".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
the album view already carries the transport, so the full-width dock was a
second bar costing the workspace a row. the host stays mounted (it owns the
engine) and only withholds its bar on /music; MusicMiniBar draws the scrubber
at the foot of the library panel, with play/pause and the lyrics toggle for
when you browse away from the album that's playing.
player-time now publishes duration alongside position so a scrubber outside
the host's tree can render without a 60hz state channel.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
they are indexed like any other folder — all six carry cover:true and serve a
jpeg — so the root view uses the same card as the folder grid instead of flat
gradient tiles. a root that holds tracks directly gets the hover play button
for free.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
same useGlobal MUSIC_PLAYER state, so on the album that is already loaded it
shows pause while it plays and resumes rather than restarting from track one.
any other album still starts from the top.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
the sheet forces the dark theme tokens on its own subtree — they are CSS
variables scoped to a .dark ancestor, so a light theme would otherwise paint
near-black text on black. inactive synced lines drop the /50 and use plain
muted-foreground, the same grade as the artist under each track title.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
the microphone is the same switch in two places — the play dock and the album
header — so its state moves to a channel seeded from localStorage. turning it
on splits the /music detail panel in two with a nested WorkspaceLayout: a fixed
layout, components keyed by panel id, no persistence and no registry entries.
the album view is handed to the left panel through context, so the split moves
the same element instead of remounting it and refetching the album.
playback position now reaches the pane through a module-level publisher rather
than props — it lives outside the player's subtree, and the feed ticks every
animation frame. the pane subscribes and re-renders only when the active line
changes, about once a line.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ports the mobile app's lyric parser (packages/core/src/services/lyrics.ts) to the web player and
expands a sheet above the play dock. Synced .lrc lines highlight, auto-scroll and seek on click;
plain .txt scrolls by hand. The server side already served all of this — /api/music/lyrics and the
indexer's embedded-USLT extraction — so nothing changed behind the proxy.
The sheet lives in the dock rather than a /music panel because the dock is mounted app-wide: lyrics
follow the music onto every screen, and no saved workspace layout has to be migrated to see it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The role column now has somewhere to be used from. Lists every account, changes
roles, removes members.
API — all owner-only, mounted on the existing users router:
GET /api/users list, plus the role enum so the UI never
hand-writes the names
PATCH /api/users/:id/role change a role
DELETE /api/users/:id remove an account
ownerGate uses isSuperAdmin, which now reads the role column. The global backstop
in originScopeMiddleware already confines a non-owner token to /api/auth +
/api/music, so a Member cannot reach any of this — the gate is the explicit
statement of intent and gives a clear 403 rather than leaning on a rule written
for another purpose.
The password hash never leaves the handler: listing accounts is not a reason to
hand out hashes, so the response is an explicit shape rather than the row.
The owner is refused twice over, in both handlers, before the database has to.
ck_users_owner_is_super_admin and deleteUser() would each reject it anyway, but a
raw CHECK violation surfaces as a 500 in Postgres wording, which tells the person
clicking a dropdown nothing. Same reason `isOwner` is on the wire: the UI locks
that row rather than offering an action that cannot succeed.
The menu entry is shown to the owner only. That is tidiness, not access control —
the route stays reachable and the endpoints are gated server-side, because a
hidden menu item is not a permission and anything relying on it being hidden is
already wrong. Said so in the code, next to both.
Deleting cascades — passkeys, dashboards, screens, email accounts, playlists —
and there is no undo, so it asks first and says what goes.
Not built: invitations. Creating an account still means bootstrap or a row by
hand; an invite flow needs a token, an email and an acceptance screen, which is
its own piece of work.
Untested at runtime: the routes 404 on the running server because platform TS
does not hot-reload. Everything typechecks, the token path was verified against
/api/dashboards, /api/tasks and /api/jobs returning 200, and the 404 is the
restart asymmetry rather than the wiring. Needs `pm2 restart officer`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two guards on user 1, the bootstrap account, so ownership survives whatever
happens to the rows.
ck_users_owner_is_super_admin — CHECK ((id <> 1) OR (role = 'Super Admin')).
In the database rather than in application code because the point is that it
holds against a stray UPDATE, a migration script or someone at a psql prompt,
not just against the API. A row-level CHECK can say "if this row is user 1 then
its role is Super Admin"; it cannot say "some row must be Super Admin", which
would need to see other rows. So it pins the bootstrap account and nothing else —
promoting and demoting everyone else stays free.
deleteUser() refuses id 1, because a CHECK cannot stop a DELETE and removing the
owner reaches the same end by another route: nobody who can open the vault, no
identity for the agent sidecar to run as, a web origin restricted to a Super
Admin that no longer exists, and the passkeys cascaded away so there is no
signing back in. It throws rather than returning false — its eventual caller is a
manage-users flow, where a silent false reads as "already gone".
OWNER_USER_ID is exported from the schema and used by both, so the number appears
once.
Tested on a scratch database, and the first harness was wrong — a shell variable
holding a command did not expand, every statement failed with "command not
found", and the check reported them all as allowed. Re-run directly:
insert user 1 as Super Admin allowed
demote user 1 -> Member REJECTED by CHECK
demote user 1 -> Admin REJECTED by CHECK
insert user 1 as Member REJECTED by CHECK
demote/promote users 2 and 3 allowed
deleteUser(1) refused with the message above
deleteUser(3) deleted
Pushing twice showed the CHECK adds no diff churn — still only the two known
pk_music_now_playing statements. Every row in officer_dev already satisfies it,
so it will apply without touching data.
NOT covered: nothing stops a second account also being Super Admin. The rule
asked for was "user 1 is always Super Admin", not "only user 1 is", and the two
are different constraints.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There were three answers to "who is the owner" and nothing kept them agreeing:
SUPER_ADMIN_EMAIL in .env, the lowest user id, and now the role column. Any two
of them part company the moment one is edited, and the failure is silent — an
account quietly gains or loses the vault, the platform origin, and the identity
the agent sidecar runs as. The column wins; the other two are gone.
- isSuperAdmin() reads users.role. SUPER_ADMIN_EMAIL is deleted from the code and
from .env.
- getOwnerUser() selects on the role instead of `order by id limit 1`. It returns
undefined when no row holds it rather than falling back: the agent sidecar
refusing to start beats it silently running as the wrong person.
- bootstrap creates the first account with role 'Super Admin'. Without this a
fresh install would take the column's 'Member' default and come up with NO
owner at all — no vault, no agent identity, and the web origin locked to a
Super Admin that does not exist. That bug was live the moment the column
landed; SUPER_ADMIN_EMAIL was masking it here.
Deliberately not cached. The old resolver cached an owner id, justified by "the
owner never changes at runtime (bootstrap is closed after user #1)" — which stops
being true as soon as roles are editable, and a cache with no invalidation
contract is a staleness bug waiting for whoever builds the role UI. It is one
primary-key lookup on a request that has already verified a JWT.
Verified against the live database: getOwnerUser resolves to id 1, isSuperAdmin
is true for it and false for the three Members, for a null payload and for an id
that does not exist. Then temporarily set id 13 to 'Admin' (false) and to
'Super Admin' (true) with no restart in between, which is what proves the column
is doing the deciding rather than a cache or the old lowest-id path. Reverted.
Not solved here: nothing stops the last Super Admin being demoted, which would
leave the platform ownerless. The schema cannot express it; whatever eventually
edits roles has to. Noted in the column's comment.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
begin() only sets `started` after a fetch and a full decode, so anything that
called it during that window saw started === false and started a second decode
of the same track. both finished, both called startBuffer, and only one of the
two sources ended up in `cur`.
starting a queue from a paused player did this every time: the host commits a
new queue and playing: true in one render, its queue effect calls
load(autoplay) -> begin, and its playing effect then calls play() -> begin
again, same generation.
it compounds, which is why it sounded like three songs and not two. the twin
keeps its own onended, so at the boundary advance() ran twice: the index jumped
two tracks and a second source was promoted while the first was still sounding.
three changes. begin() refuses re-entry for a generation it is already running.
onended only advances the queue if the source that ended is the one in `cur`.
and every source is registered in a `live` set, because cur/nxt is what the
engine reasons about while `live` is what it is responsible for silencing — an
untracked web audio node cannot be stopped by anything except closing the
context.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`bun db:push` planned 32 statements against a database that already matched the
schema, and stopped on a "do you want to truncate screens?" prompt that answering
could not resolve — the same question came back next run. Root cause found and
fixed rather than worked around.
drizzle-kit mis-diffs named composite unique CONSTRAINTS. It reads one back,
compares it against a schema declaring the identical name, columns and order,
decides they differ, and emits DROP + ADD. Fifteen of those, forever. Reproduced
on a database drizzle had itself created seconds earlier, so it is not drift.
Single-column .unique() is diffed correctly; only unique('name').on(a, b) is
affected. Unique indexes go through a different code path and are stable, so all
fifteen are now uniqueIndex.
A unique index enforces exactly what the constraint did — verified, a duplicate
insert still fails on uq_screens_user_name — and onConflictDoUpdate accepts it as
an arbiter. It cannot be a foreign-key target, but nothing here targets a
composite key; checked before converting.
Separately, user_integrations_server_integration_id_server_integrations_id_fk is
65 characters and Postgres truncates identifiers at 63, so drizzle compared its
generated name against the stored, truncated one and re-created the FK every run.
Declared explicitly as fk_user_integrations_server_integration.
Measured on a scratch database, pushing twice each time:
before 32 statements, interactive prompt
after uniqueIndex 4
after FK fix 2
The two that remain are a composite primaryKey with the same bug and no index
form to escape to — music_now_playing re-creates pk_music_now_playing every push.
Silent, no prompt even with rows, data unaffected, and naming it explicitly does
not help. Documented as expected.
The conversion itself was tested against populated tables, since that is what the
real database will do: no prompt, and all rows survived.
Docs rewritten in src/databases/CLAUDE.md — the rules committed an hour ago
described the broken behaviour and would have been wrong the moment this landed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
the letter that asked for the provisioning endpoint and the reply that answers
it. both lived only in an untracked COMMS/ directory on the dev box, which is
where the reasoning behind an api contract goes to be lost.
the feedback comes with it: a reply that answers a letter nobody can read is
half a record.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds `role` to users as a text column with a TS enum, matching how `status` is
done — no pgEnum, so adding a role stays a one-line schema change rather than an
ALTER TYPE while the set is still settling. USER_ROLES and UserRole are exported
from officerdb so the API and UI enumerate them from the column definition rather
than a second hand-written list. Defaults to 'Member', the least privileged, so a
row created by a path that does not think about authorisation cannot mint an
admin. NOT YET PUSHED — the column is in the schema, not in the database.
The larger half of this commit is a rule for anyone running `bun db:push`,
because the first time you run it, it looks like your change broke something.
It plans 16 statements against a database that already matches the schema, and
plans them again on the next run. Two separate causes, both diagnosed here:
- it drops and re-adds every NAMED COMPOSITE unique constraint — all 14, from
uq_screens_user_name to uq_wallet_labels_wallet_kind_ref. Single-column
.unique() diffs correctly and is untouched; only unique('name').on(a, b) is
affected. Names, columns and order in the database are identical to what the
schema declares. drizzle-kit 0.31.9 / drizzle-orm 0.45.1.
- it drops and re-adds one foreign key because
user_integrations_server_integration_id_server_integrations_id_fk is 65
characters and Postgres truncates identifiers at 63, so drizzle compares its
generated name against the stored, truncated one and always differs.
The rules, ranked by how much damage getting them wrong does: never answer "Yes,
truncate the table" — it destroys rows and does not help, since the constraint is
re-added next push either way; never delete a constraint from the schema to
silence the prompt, because upserts depend on it existing; do not reach for
--force until someone has established on a scratch database which branch it takes.
Run with --verbose first and read what it is actually planning.
Pointers added to platform/CLAUDE.md and the workspace root CLAUDE.md, since
those are what a session reads before it ever opens the database directory.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
mints a dav app password, renders a configuration profile carrying both the
caldav and carddav payloads, and parks it behind a single-use five-minute token
that safari can fetch without a session.
one profile with both payloads is not a convenience: ios keys accounts by
server+username, so adding carddav separately gets folded into the existing
caldav account and contacts silently never appear.
the profile holds the password in plaintext, so it is held in memory only —
persisting it would falsify createDavAppPassword's "not stored" guarantee.
signing is opt-in via DAV_PROFILE_SIGN_CERT/_KEY/_CHAIN and off by default;
this box has no tls certificate, tls terminates upstream. signed at mint time
reading the cert from disk, so a renewal needs no restart and no hook.
the download route is registered before the /dav mount because hono matches in
registration order and the sync door's /* would otherwise demand http basic.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The three non-owner accounts had no directory of their own — only the owner did,
grown organically as features created what they needed. Adds an idempotent script
that creates the skeleton, and runs it for those three.
DATA_PATH/<email>/{home,attachments,cache,dashboards,email_accounts,
general_chat_sessions,logs,sidecar}
Most of those are also created on demand by whichever feature owns them, so
pre-creating buys legibility rather than function: the tree now shows the shape a
user has without having to use it first. `home` is the exception and the reason
this exists — nothing creates it today, because getOwnerHomeDir returns
process.env.HOME_DIR whenever it is set, which it always is on a real install. It
is where a non-owner's sessions will run.
Takes emails as arguments rather than reading the user table. The layout does not
depend on the database, keeping the DB out means it runs with nothing else up,
and at invite time the caller already knows the email. It refuses arguments that
are not email addresses, since a stray one would create a junk directory sitting
next to real user roots and looking like one.
Deliberately NOT a revival of the provision.ts deleted two commits ago. That one
was ~90% per-container scaffolding — shell rc files, a generated CLAUDE.md and
settings.json describing the container — wrapped around the two useful lines this
keeps.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The first iteration gave every user their own Docker container: the user's whole
world lived inside it, and only the super admin could see the real filesystem.
That model is gone, but its scaffolding was still in the tree, and it had already
cost time today — the /usr/local/bin/claude symlink removed a few commits ago
existed only because the bwrap jail ro-bound /usr and could not see the
installer's target.
Deleted:
generate-container-context.ts built the CLAUDE.md and settings.json that told
an agent what its container looked like. Its
only importer was the provisioning removed in
the previous commit, so it had zero consumers.
getUserPiConfigDir pointed into the managed container home. No
consumers anywhere in the tree.
Renamed:
DATA_PATH/<email>/.container-context -> agent-config. It holds one file, the
MCP server config handed to the CLI, and has nothing to do with containers. The
path is written and consumed through a return value, so nothing else reads it;
an old directory left on disk is inert.
Documented rather than removed, because both still have live callers and pulling
them out is a refactor rather than a cleanup:
getHomeDir the container's home. Nothing executes there now — terminals,
chats and task runs all use getOwnerHomeDir — but it survives
as that function's fallback and in pipeline-executor.
toShellUsername named for deriving a Linux username inside the container,
32-char limit and all. Nothing creates a Linux user now; the
value ends up only as a claim in the signed task token, so it
is a sanitiser wearing an old name. Unpicking it means
changing that token and WSData.
Nothing to clean on disk: DATA_PATH/<email> has no home/ tree and no
.container-context/. The docs that still mention any of this are the two marked
"Historical" at the top, which are records of what was true then and should keep
saying so.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
provisionUserEnvironment seeded a managed home under DATA_PATH/<email> — shell
configs from a template dir, plus a generated CLAUDE.md and settings.json — and
bootstrap called it fire-and-forget when the owner account was created. It is
outdated: the managed home is not where anything runs. Task runs, terminals and
chats all execute in getOwnerHomeDir (the real login home, HOME_DIR), not the
getHomeDir tree this was populating.
Removed the function, its four template files, and the call in bootstrap. No
setup script referenced it — checked all of scripts/*.sh — and bootstrap was its
only caller anywhere in the tree.
getHomeDir and toShellUsername stay in data-path.ts: pipeline-executor and
pipeline-job-manager still use them.
This leaves src/servers/generate-container-context.ts with zero consumers, since
provision was the only thing importing it. Left in place rather than deleted in
the same commit — it is 173 lines that build a CLAUDE.md and a settings.json for
an agent, which is plausibly wanted somewhere else, and that is a call for the
owner rather than a side effect of this cleanup.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A light install is reached at localhost on the machine running it, so PUBLIC_URL
has exactly one right answer. setup.sh required it with a re-prompt loop; under
the light profile it now defaults to http://localhost:$PORT. The macOS installer
already did this — this is parity, and it removes the one prompt in a light run
whose answer a non-technical user could not be expected to produce.
setup_mac_light.sh also wrote PUBLIC_BUILD_ENV="development", justified in a
comment as "what makes plain http://localhost work". That is no longer true, and
the cost of it is not small. IS_DEV_BUILD gates exactly three things:
origin validation already off regardless — ALLOW_ANY_ORIGIN defaults to true
password rules validatePassword is skipped entirely on change-password
rate limiting the limiter returns next() before doing anything
So the only live effects were losing the last two, for a benefit that another
default already provided. It now writes "production", matching setup.sh. Nothing
about localhost needed relaxing: browsers treat http://localhost as a secure
context, so passkeys, getUserMedia and the clipboard all work over plain HTTP,
and passkeys in particular derive their RP ID from the request origin rather
than a configured domain.
That last point is the boundary worth knowing: http://192.168.x.x is NOT a
secure context, so reaching a light install from another device means putting
an HTTPS proxy in front of it. Recorded in the comments at both prompts.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Renamed for parity now that Linux has a light profile too:
scripts/setup_mac.sh -> scripts/setup_mac_light.sh
ecosystem.mac.config.cjs -> ecosystem.mac.light.config.cjs
The macOS process list was still a hand-copied subset, which is the shape that
broke it: written 2026-07-28, within days it was running the Anthropic proxy
under the name officer-claude with nothing spawning `claude`, and pointing at a
pty entry point that had moved. Both silent. It now declares names and reasons
and reads script/args from ecosystem.config.cjs, so a launch change on the host
reaches it for free.
The include/exclude checks moved into ecosystem.profile.cjs rather than being
copied into the second profile — duplicating the guard rails would have repeated
the mistake they exist to catch. Both profiles were re-tested against a mutated
host ecosystem: renaming an included app and adding an unclassified sidecar each
throw in both, and an unmodified host loads five apps in both.
Kept as two files rather than collapsed into one, even though they currently
produce identical output. The exclusions do not mean the same thing: on macOS
officer-vnc CANNOT run, there being no Xorg; on a Linux light install it could
run fine and you have chosen not to. Merging them would lose that, and they
diverge the moment one profile gains something the other cannot have.
setup_mac_light.sh's verification loop now reads app names with node instead of
grepping for `name:` — the derived profile has no literal keys, so the grep
would have silently listed no services at all, which reads the same as a healthy
install with nothing configured.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
OFFICER_PROFILE=light installs the same thing the macOS build does, on Linux:
the file browser, the terminal, and Claude/opencode chat. It skips the archive
extras, the sudoers entry and auto-suspend disabling, Go, Rust, PulseAudio,
cliamp, Neovim, the shell tooling and yt-dlp, brings up Postgres alone of the
five Docker services, and starts ecosystem.light.config.cjs. Unset or `full`
behaves exactly as before.
The profile changes which processes start, not which code ships — every API
route stays mounted, so features whose sidecars are absent report themselves
unavailable rather than disappearing.
ecosystem.light.config.cjs DERIVES its apps from ecosystem.config.cjs rather
than copying them, because the hand-copied Mac list was broken within days of
being written by a sidecar split in two and a pty entry point that moved, and
both failures were silent. Here a script/args change on the host propagates for
free, and two consistency checks turn the silent cases loud:
- a name the profile needs that the host no longer defines throws at load
- an app added to the host that is in neither the include list nor the annotated
exclusion list throws, so a new sidecar cannot default to "not in the profile"
without someone deciding
Both were tested against a mutated copy of the host ecosystem: renaming
officer-agent and adding an unclassified sidecar each throw, and the unmodified
file loads five apps.
The verification block now reads app names with node instead of grepping for
`name:` — the derived file has no literal keys to match, so a grep would have
silently verified nothing — and skips the checks for tools the profile did not
install, so a clean light run does not report Go and cliamp as missing.
Full-profile behaviour is unchanged by construction: every guard wraps the
original code in an else branch. The preamble was tested across unset, full,
light and an invalid value.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ecosystem.mac.config.cjs was written on 2026-07-28 and broken within days by two
changes it never caught up with, leaving two of its four processes dead:
- it ran `officer-claude` against src/servers/sidecar/claude/index.ts. That file
is the Anthropic credential proxy; the process that actually spawns `claude`
is user-instance.ts, which was never started. Chat had a credential holder and
nothing driving it. Split into officer-anthropic-proxy + officer-agent to
match the host, and retired the `officer-claude` name that caused it.
- officer-pty pointed at src/servers/api/terminal/pty-sidecar.mjs, which no
longer exists; the sidecar moved to src/servers/sidecar/pty/index.mjs. The
terminal could not start at all.
No startup ordering is needed between the proxy and the agent: the agent reads
the proxy secret from disk and, when it is not there yet, warns and re-reads
before the next spawn.
Five sidecars have been added since the file was written (memos, caldav, photos,
notify, wallet). All are excluded, and every exclusion is now listed with its
reason so the next reader can tell "not applicable on macOS" from "forgotten" —
which is the failure this commit is fixing.
setup_mac.sh needed no path corrections; its verification loop derives the
process names from the ecosystem file, so it picks the new list up on its own.
Two gaps closed there:
- it called `pm2 save` without `pm2 startup`, writing a process list that
nothing reads — a reboot left the machine with nothing running. Added the
launchd equivalent behind SETUP_BOOT, optional because a laptop is not a
server, and non-fatal because pm2's launchd integration can want a prompt.
- the closing "not installed on macOS" note listed four omissions when there are
now fourteen.
Both files parse and every ecosystem entry point resolves to a file that exists.
The launchd path is the one thing not verified here — it cannot be exercised
from the Linux host.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The symlink existed because the Claude sidecar hardcoded that path, and that
hardcoding came from the bwrap-sandboxed architecture: the jail ro-bound /usr
and could not see the installer's real target in ~/.local/bin. The sandbox is
gone, and claude-manager.ts now resolves the CLI itself — $CLAUDE_BIN, then
PATH, then ~/.local/bin/claude, /usr/local/bin/claude, /opt/homebrew/bin/claude.
Verified before removing rather than assumed:
- the only references left in the tree are the resolver's own fallback list and
this step; nothing in capabilities, no systemd unit, no crontab, no ecosystem
file and no shell rc mentions the path
- the agent sidecar's PATH under pm2 contains ~/.local/bin ahead of
/usr/local/bin, so Bun.which resolves to the installer's target and the
symlink is never consulted
- replaying the resolver in that exact environment with the symlink treated as
absent returns the same path, so it is not load-bearing
- resolveClaudeBin runs at claude-manager module scope, which ES import ordering
puts before user-instance.ts reassigns process.env.HOME — so the homedir()
candidate is evaluated against the real home, not the managed one
The install-and-verify step above is untouched, so a failed claude-code install
is still reported. Only the sudo-owned link into /usr/local/bin goes, a
directory macOS does not ship at all.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three files, all additive — nothing on master is modified by this beyond the
CLAUDE_BIN change below, and no file is deleted. The branch predates master by
about 180 commits, but it touches nothing master has touched since, so the
merge is clean.
The part that matters beyond macOS is claude-manager.ts. CLAUDE_BIN was pinned
to /usr/local/bin/claude, which dated from the bwrap-sandboxed architecture:
the jail ro-bound /usr and saw nothing else, so the installer's real target
(~/.local/bin/claude) had to be symlinked somewhere the sandbox could reach.
That sandbox is gone, and the hardcoded path left the sidecar unrunnable on any
host without it. It now resolves an explicit CLAUDE_BIN pin, then PATH, then the
locations Anthropic's installer actually writes to — mirroring how OPENCODE_BIN
is already resolved in the opencode sidecar.
ecosystem.mac.config.cjs is deliberately a trimmed set of processes rather than
a mac port of the full ecosystem. It is also stale in two specific ways, left
as-is here and worth fixing separately: it names officer-claude, which master
renamed to officer-anthropic-proxy, and its officer-pty runs
src/servers/api/terminal/pty-sidecar.mjs, which moved to
src/servers/sidecar/pty/index.mjs.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
One-tap calendar/contacts setup for the iOS and Android apps: the server
contract that already exists, and specs for the two platform mechanisms
that don't yet — a signed .mobileconfig for iOS and a DAVx5 intent for
Android.
Payload keys, intent identifiers and install flows are researched against
Apple's device-management reference and davx5-ose source rather than
recalled; the doc marks what is verified against a running deployment and
what is still specification.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The setup wrote /etc/sudoers.d/officer-service with tee and then chmod'd it.
Two problems, both with the same worst case: a malformed or wrongly-permissioned
file there breaks sudo completely, and you cannot sudo to repair it — on a
remote machine that means physical access or a rescue boot.
Generate into a temp file, gate on `visudo -c`, and only then install. Use
install(1) rather than tee+chmod so the content and the 0440 mode land in one
step; tee creates at the default umask first, and sudo refuses to read a sudoers
file with loose permissions, so the old ordering had a window where sudo could
reject its own configuration.
The re-run guard also grepped for the username anywhere in the file, so a
comment mentioning it counted as configured. Match the actual rule instead.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two guards that checked something other than the state they were protecting.
Section 17 skipped the entire remote desktop setup when `dpkg -s ubuntu-desktop`
succeeded, treating one package being present as proof that seven steps of
configuration had run. A host can have ubuntu-desktop and still be missing GDM
auto-login, the forced Xorg session, the captured EDID and its kernel command
line, and the login-time mode setter — which is exactly what this machine was
on 2026-08-02, while the guard cheerfully reported "skip". setup-desktop.sh is
idempotent throughout, so the guard bought nothing and cost a converged host.
The starship step had the opposite bug: it cp'd over ~/.config/starship.toml on
every run, so a customised config was silently destroyed. The nvim step two
sections down already guards on its config's existence; this now matches, and
distinguishes "absent" (deploy) from "identical" (skip) from "yours differs"
(keep, and say how to take ours).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
a DAV client tells you almost nothing when it fails — iOS reports every setup
failure as "Cannot connect using SSL" regardless of cause — so the only way to
know whether a phone ever asked for an address book is to record that it did.
one line per proxied request with the client's own user-agent, and a warning
when a credential is present but wrong. a missing credential is the normal
opening move and stays unlogged; a wrong one is indistinguishable from it at
the client end, where both just say "password incorrect".
no body is ever logged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
two bugs, both of which iOS reports as "Cannot connect using SSL" — a message
about TLS for a problem that has nothing to do with TLS. the certificate was
never involved.
the well-known routes were registered with .get. RFC 6764 §6 has the client
probe the well-known URI with the method it actually intends to use, and iOS
sends PROPFIND — which fell through to the SPA catch-all and 404'd. they
answered correctly in a browser, which is why they looked healthy.
hono's cors() answers every OPTIONS itself as a preflight and never calls the
route beneath it, so OPTIONS /dav/ returned a bare 204 with no DAV header.
OPTIONS is not a preflight to a DAV client — it is how the client asks what the
server can do, and iOS refuses an account whose server does not advertise
calendar-access. cors now skips the DAV paths entirely; a CalDAV client is not
a browser and has no origin to check.
verified from the public internet: PROPFIND on both well-known paths 301s to
/dav/, and an authenticated OPTIONS now returns
`DAV: 1, 2, 3, calendar-access, addressbook, extended-mkcol`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
cleanup-desktop.sh still described the era when Officer installed a desktop of
its own — XFCE on a TigerVNC Xvnc — so tearing the remote desktop down meant
deleting a desktop nobody else used. That stopped being true when the setup
moved to mirroring the machine's existing session with x11vnc, and the script
was left actively dangerous: it purged dbus-x11 and Brave, deleted ~/.vnc, and
reinstalled gnome-keyring, all of which the current GNOME setup depends on or
deliberately removes. Running it today broke the desktop rather than cleaning
it up.
Rewritten as the actual inverse of setup-desktop.sh:
- removes what Officer added — x11vnc, ~/.vnc, the GDM auto-login and forced
Xorg keys, the forced EDID and its kernel command line, the login-time mode
setter, the legacy officer-vnc unit, the VNC entries in .env
- keeps ubuntu-desktop, gdm3 and dbus-x11, which are the machine's own desktop
and not Officer's to delete
- still purges XFCE and TigerVNC when present, since a host set up by the older
script carries them and they are precisely what this undoes
- Brave is opt-in behind --purge-brave: setup installs it, but by teardown time
it is usually just the user's browser with their profile in it
The GRUB and GDM edits were checked against copies of the real files: stripping
the EDID parameters leaves other kernel arguments intact wherever they sit in
the line, and the GDM revert does not disturb the commented examples that ship
in custom.conf. The package matcher names each xfce-family prefix rather than
globbing '^libxf', which would have taken libxfixes, libxft and libxfont with it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
two workspace screens backed by one app folder: the collection list on the
left, the selected calendar's agenda or address book on the right. both read
the officer-caldav sidecar through the /api/caldav auth proxy, which holds no
DAV credentials of its own.
the selected collection is a DAV path with slashes in it, so it lives in
?collection= on the same route rather than as a path segment — still in the
URL, still bookmarkable, no selection channel. an absent or unknown value
resolves to the first collection inside the panels instead of redirecting,
because until the list has loaded there is no canonical URL to redirect to.
the agenda is a grouped list, not a month grid: the sidecar returns RRULE
unexpanded, so a grid would have to invent occurrences the server never
claimed existed. a repeating event gets a badge instead.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The nightly full rebuilds every album by definition, so "it completed" says
nothing about whether it is still needed. Diff the from-scratch build against
the index that was already live, just before the slot swaps in, and log the
delta: albums added, removed, and entries whose contents changed.
A run reporting NONE did no useful work. A string of those is the evidence for
retiring the nightly; a delta that keeps coming back names the albums to go and
look at instead of guessing.
Entries are compared field by field rather than by JSON.stringify: the optional
fields are spread conditionally, so two entries built by the same code can
serialise with different key order, and stringify would report every album as
changed every night. A cache-format upgrade is labelled, since that rebuilds
everything legitimately and would otherwise read as total rot.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
restarting officer under a live turn left the browser connected but permanently
silent. the sidecars are pm2 peers, so the agent kept generating and kept
committing to chat_session_events — what died was officer's binding to it. on
`resume-cursor` the server only re-attached the socket when an in-memory session
still existed, so after a restart there was no session and, critically, no
session-scoped subscription relaying sidecar events to the client. the client got
its durable replay and then nothing, which reads exactly like the agent stopping.
adopt the session instead: recreate the record and re-open the subscription
without spawning anything. `_claudeKill` has to be set as part of that — handleChat
treats its absence as "first turn" and would open a second subscription, doubling
every message.
the client now echoes the model and cwd from its session:init back in the
handshake, since after a restart it is the only party that still remembers them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Settings → Integrations → Calendar & Contacts sync. without this there is no way
to mint a credential from the app, and minting one was the first step of testing
the whole caldav feature on a phone.
the generated password is returned by POST and never again — it is stored as an
argon2 hash, so there is nothing to read back. that one fact drives the screen:
the new credential appears in a panel that stays until dismissed, with the
server url and username beside it, because once it is gone the only remedy is to
revoke and mint another.
the server url is read from window.location.origin rather than configured. it is
by definition the address that reached this page, so it is the one that will
work on the phone.
rows show the hint prefix and last-used date. "never used" is the tell that a
device was set up wrong, so it gets its own wording rather than a blank.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
step 3 of docs/nextcloud-replacement.md. collections, events and contacts as
plain json, so the browser never has to parse multistatus xml to draw a list.
this talks dav to radicale over loopback rather than reading its on-disk format.
the storage layout is radicale's private business and changes between versions;
propfind is its supported interface and costs one in-process hop. parsing the
storage directly would be faster and would break silently on upgrade, which is a
bad trade for a calendar.
three bugs found and fixed while verifying, all of which fail quietly rather
than loudly:
calendar-data and address-data are NOT webdav live properties. rfc 4791 and
6352 define them as report-only, and radicale correctly returns an empty prop
for them under propfind — so the first version returned a 207 full of nothing,
which reads exactly like "your calendar is empty".
the principal resource matched the calendar test, because `<C:calendar-home-set/>`
satisfies /calendar\b/. the principal showed up in the list as a calendar
called "1". the tag has to be required to end.
the internal fetcher omitted X-Script-Name, so the ui was handed /1/work/ for
the same collection a phone sees as /dav/1/work/, and nothing downstream could
have matched them up.
ical.ts is deliberately small: it unfolds lines and pulls out the fields a list
shows. it does NOT expand rrule or resolve vtimezone — radicale owns correctness
there, and rrule is passed through raw so the ui can say "repeats" without
either of us pretending to know when.
verified against the running stack with a real vevent and a real vcard, then the
fixtures were removed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>