Implements docs/deprovision-os-account.md. Until now deleteUserHandler removed the row, cascaded the
database, and left the entire Linux side running — measured on production on 2026-08-12: working login
shell, healthy postgres container, 454M of data, uid queued for the next useradd to reissue along with
everything still owned by it.
The load-bearing rule from the spec: sever the data from the uid BEFORE releasing the uid, and if
severing fails, do not release. A failed deprovision is not a broken account, it is a trap for whoever
is created next.
Sequence: disable-linger, terminate-user, reap-and-prove, chown -R, userdel (never -r).
reap terminate-user is not a barrier. Production measured a three-hour-old `/bin/zsh -i` surviving
it AND the removal of /run/user/<uid>. So: pkill, bounded wait, pkill -9, bounded wait, and a
final count that must be zero or the account is not released.
chown fixes the uid and subuid halves in one pass — it rewrites every file it walks whatever owned
it. The range is still captured first, because userdel removes the /etc/subuid entry and after
that nothing on the machine remembers what it was. It is returned on every path including the
failures, and logged as the exact assert-uid-free.sh command line.
Two guards the spec did not ask for, both pure and unit-tested:
guardDeletable ensureOsUser's adoption rule backwards. Deletable only if the passwd home is the one
the platform would have confined, and uid >= 1000. Without it `userdel root` is one
bad users.osUser away and nothing else in the sequence would object.
guardMemberTree the tree must resolve to a direct child of DATA_PATH. The email reaches join() from a
database row and the result is the argument to a recursive chown.
chown runs with -h. Measured here that `chown -R` already declines to follow a symlink out of the tree and
re-owns the link itself, but the argv should say so rather than rest on traversal semantics — and
re-owning links is what makes `find -uid` (lstat) a meaningful check afterwards.
destroy exists, has no call site, and is chown-then-delete-as-the-service-user rather than sudo rm -rf, so
a recursive root delete built from a database column does not exist in this codebase.
deleteUserHandler now runs this FIRST and refuses to delete the row if it fails: the row is what remembers
there is anything to clean up, so deleting it first makes a failure unrecoverable through the UI.
NOT YET RUN AGAINST A REAL ACCOUNT. Only the pure guards have tests. The five-step validation is in the
doc; it needs the production host, a shell left open, and a container writing as a non-root user — the two
cases the quiet path passes vacuously.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
20 KiB
TODO
Deferred work.
Context, corrected 2026-08-07. This file used to open by saying Officer was "collapsing from multi-tenant / open-source-ready to a single-user platform", and told you to treat multi-tenant indirection as accidental complexity. That direction was reversed. The capability permission model shipped on 2026-08-07 to serve a real goal — deploy to the company server, onboard people, give each one their own Gitea account through the platform. Per-user scoping is now a requirement, and the items below that proposed deleting it have been removed rather than left to mislead the next reader.
What did NOT reverse: execution capabilities (terminal, chat, tasks, files, desktop, browser) run as
the owner's OS user and can never be granted. Indirection there really is accidental complexity.
Multi-user
-
deprovisionOsAccountis written but has never run against a real account. Landed 2026-08-12 inos-user-deprovision.tsand wired intodeleteUserHandler, which now refuses to delete the row when the Linux teardown fails — so a failure is retryable instead of forgotten. Only the pure guards (guardDeletable,guardMemberTree,parseSubUidEntry) have tests; the reap loop, thechown -Rsever anduserdelhave been exercised by nobody.docs/deprovision-os-account.md→ "What is still unproven" has the five-step validation, and it has to happen on the production host with a throwaway account that has a shell left open and a container writing as a non-root user — those are the two cases the quiet path passes vacuously. -
The terminal replays terminal QUERIES, which get typed into the shell.
sidecar/pty/sessions.mjsreplays the whole scrollback on attach; query sequences in the buffer get re-asked, xterm.js answers, and the answers arrive as keystrokes. Visible to a member daily. Fix is to strip query sequences inappendBuffer, so a replay reproduces output and never re-issues requests. -
The web terminal renders a long URL as unreadable fragments. Claude Code's first-run login prints a ~400-character OAuth URL; the web terminal shows scattered characters with large gaps, nothing selectable. Half worked around by
2a8f004(OSC 52, so "press c to copy" reaches the clipboard) — the rendering itself is undiagnosed. This is every new member's first five minutes.docs/open-threads-after-per-user-claude.md§1 has what is known and where to start. -
Agent sessions are not durable, and it is one property behind three symptoms. A sidecar restart loses session identity, which is why
endTurnIfAgentIsGonemust skip sessions with no recordeduserId, why a stuck "generating" spinner survives until a reconnect, and why any crash in that process is destructive rather than merely inconvenient. Fixing the three separately would miss that they are one missing property.docs/open-threads-after-per-user-claude.md§2. -
ProcessTransport is not ready for writing— survivable since8c4f150, still unexplained. A floating rejection inside the SDK's own input pump, with no frames from our code, so noawaitof ours can catch it. It crashedofficer-agentfour times on 2026-08-11, once truncating a turn mid-sentence; theunhandledRejectionbackstop has caught it once since. Best hypothesis is theclaudeCLI exiting whilestreamInputis still pumping. It needs looking at after the next occurrence, not catching in the act — markers to grep indocs/open-threads-after-per-user-claude.md§3. -
No way to create a second account. Fixed 2026-08-11 on
sidecar-app-store:POST /api/users(api/users/create-user.ts, owner-gated) plus an Add-account form in Settings → User management. Created accounts arestatus: 'Active'— the column defaults to'Unverified'andsignin.tsrefuses anything else with a bare UNAUTHORIZED, which is the trap the hand-INSERT route fell into. The owner sets the password and reads it out;passwordChangedAtstays null. Directories come from the sharedprovisionUserDirs/USER_DIRSindata-path.ts, whichscripts/provision-user-dirs.tsnow imports rather than restating. -
Still no invite flow, and no password reset for a member. The owner types the password and tells the person, which means the owner knows it and the member cannot change it back if they forget theirs — recovery today is delete-and-recreate. An invite (token, expiry, member sets their own) needs a mail path. This is the next piece, not a nice-to-have.
-
A second Super Admin was storable, and made the owner nondeterministic. Fixed 2026-08-11.
ck_users_owner_is_super_adminpins user 1's role but a row-level CHECK cannot see other rows, andupdateUserRoleHandlerhappily promoted anyone — whilegetOwnerUser()wasWHERE role='Super Admin' LIMIT 1with no ORDER BY. Two holders would have made "who owns this server" a question the query plan answered, and that answer feeds the agent sidecar's identity, vault access and origin scoping. Both write paths now refuse the role, the list endpoint offersassignableRoleswithout it, andgetOwnerUser()orders by id. -
dashboards.idis a global primary key, and ids areslugify(name). Two accounts cannot both have a dashboard named "Home". Reachable today: six accounts exist. The recommendation on the table is a composite PK(user_id, id)— it matches theuq_dashboards_user_idindex already there and keeps every storedws-layout-<id>address valid, which uuid ids would not. Note the drizzle composite-PK re-diff quirk indatabases/CLAUDE.md. Full analysis indocs/workspace-panel-todo.md§3. -
capabilities/authorize.tshas no automated tests.registry.test.tscovers the pure registry functions and the totality check; the resolver that does the owner bypass, the grant lookup, the role cache and the fail-closed catches is exercised only by hand. It is the file standing between a Member and a shell. -
No empty state for a denied screen. A member who reaches a route their role lacks gets a broken panel or an endless spinner rather than a clean refusal.
-
getOwnerHomeDir(email)ignores its argument wheneverHOME_DIRis set, which it is here — every caller resolves to the owner's real login home. Safe only because all seven callers sit behindexecutioncapabilities. If per-user home confinement is ever attempted, this is the function to start from. -
pty,vaultandopencodereceive no identity at all. Every other sidecar validatesX-Officer-User. The pty sidecar keys purely on asessionIdfrom the query string and its/_officer/sessionsendpoints list and kill every session on the box; vault and opencode take no user argument. All three are covered today only becauseterminal,vaultand the agent are owner-only capabilities — that is a correct outcome resting on the wrong layer, and it is the thing to fix first if any of them is ever granted. -
Radicale is configured
type = owner_only(sidecar/caldav/radicale.ts:54) while the caldav sidecar itself is fully per-user and confines every JSON read to/dav/<userId>/. The platform side is ready for members; the CalDAV server underneath is not. -
The music library is one global index.
sidecar/music/indexer.tsreadsHOME_DIRand serves every account from it. Favourites, playlists and now-playing are per-user. Deliberate for now (one household, one library) but worth stating rather than discovering. -
markInterruptedJobs()andgetOldestPendingJob()are platform-wide. The pipeline queue is a single global lane; ownership is enforced one layer up, inpipeline-jobs-routes.ts, by an explicitjob.userId !== user.id → 404on every by-id route. Correct today, but the queue itself has no notion of whose work it is running. -
Cross-user writes in the notify sidecar (fixed 2026-08-07, this session).
DELETE /_officer/devices/:tokendeleted by token with no user predicate, so any account with thenotifycapability could deregister another's device; andPOST /_officer/notifylet a request body'suserIdoverride the proxy-injectedX-Officer-User, so the same account could push to another's devices.deletePushDevicenow takes an optionaluserId(the route passes it, the APNs/FCM dead-token paths deliberately do not) and the header now wins over the body. -
Remove dead
usernameplumbing.send-claude-code.tsdeclaresusernamein two types without using it. (toShellUsernameis NOT dead —server.tsx:190andpipeline-job-manager.ts:267both call it. Theprovision.tscaller this item used to name no longer exists.)
-
Gmail-style search operators (done 2026-07-24,
0e5b91e).parseEmailQuery+searchEmailsinsidecar/email/store.tsparse:from:/to:/subject:/body:→ FTS5 column filters;has:attachment,is:unread/is:read,label:X,before:/after:YYYY-MM-DD→ SQLWHEREonemails; quoted values → exact phrases; free text → prefix-AND full-text; unknownop:valfalls back to free text. Structured-filters-only queries skip the FTS join. Validated on the real 18k-mail DB. Search box placeholder hints at operators.OR(done 2026-07-24,85d38fb). Query splits on top-level uppercaseORinto branches; each branch is a self-contained condition (id IN (FTS subquery)+ its SQL filters) and branches are OR'd — so OR works across full-text and structured filters. Verified on the 18k DB (from:github OR from:deepgram= 90+7 = 97 exactly).- Remaining: parenthesised grouping (nesting
(a OR b) c),in:sent/inbox/spam/trash(folder scope), and relative dates (newer_than:7d). Grouping needs a recursive parser. - Group search results into threads too. Folder views group by conversation (
ee11c94), but/email/searchstill returns one row per message. Apply the samethread_idcollapse to the FTS result set so search matches Gmail's grouped results.
-
Conversation threading (done 2026-07-24,
ee11c94).thread_idcolumn onemails: header-based for new mail (id=sha1(Message-Id), soReferences[0]hashes to the root's id —computeThreadIdinsidecar/email/store.ts), subject+counterpart backfill for already-synced mail./messagescollapses to one row per thread (window fn) with count/unread;/thread/:id+/thread/:id/read; reader renders a collapsible stack. Verified on synthetic + 18k real DB.- Upgrade old mail to exact threading. One-time full re-fetch to capture
Referencesfor already-synced mail — replaces the subject+counterpart fallback (which can over-merge recurring same-subject mail from one sender). Heavy/network-bound; opt-in. - Store the RFC
Message-Idheader on ingest so replies can set a realIn-Reply-To(currently reply threading leans on subject/participants;idis a local hash, not the header).
- Upgrade old mail to exact threading. One-time full re-fetch to capture
-
Compose/reply/send (done 2026-07-24). Gmail SMTP send with contacts autocomplete, rich contenteditable body (inline images at the caret via paste/drag-drop, Bcc), single close button.
-
busy_timeouton the email db (done 2026-07-24,9527e0b). API + email sidecar shareemails.db; wait out a concurrent writer instead of 500-ing with "database is locked". -
Multiple views in the Email screen (tabs). The screen grows beyond the current mailbox into several switchable views:
- Sender/domain management view. Left panel: controls to group mail by sender or by domain (room for more grouping axes later). From a group, run bulk actions on that sender/domain: unsubscribe + mark irrelevant, delete all mail from it, block/ignore future incoming, etc. Think "inbox cleanup / triage" — operate on a whole sender at once. - Needs: aggregate query (count/size per sender + per domain), a block/ignore list the sync/IDLE path honors on incoming, an unsubscribe action (List-Unsubscribe header / link), and bulk delete over a sender/domain.
- Chat view. The existing AI email-assistant flow — likely stays roughly as-is, just lives as one of the tabs (main view).
- Open question: tabs vs. some other view switcher; which view is default.
Transmission
Phase 1 (parity with _references/transmission-web) shipped 2026-07-30: the officer-transmission
sidecar plus /transmission (torrents / stats / settings). Everything below is phase 2 — none of
it exists in the reference app, so none of it was in scope for parity.
Probably needed regardless
- Resizable detail pane.
TorrentsViewpins it atDETAIL_HEIGHT = '45%', which is a guess. Either a drag handle or a persisted height inuseLocalStorageState, alongside the column set. - Revisit
DEFAULT_VISIBLE_COLUMNS. Eleven of thirty, chosen before ever seeing the table rendered against the real 43-torrent library. Likely wrong in both directions. - Files tab on huge torrents.
detail/FilesTab.tsxbuilds the whole tree and renders every node — no virtualisation. Fine at 14 files; unknown at a few thousand. If it stalls, the fix isuseVirtualizerover a flattened visible-node list, the same shape asTorrentTable.
Ideas, unprioritised
- Container→host path mapping. The daemon reports its own namespace (
/downloads/complete), which is not a path on this host. A mapping table (/downloads→~/hdds/08_TB_01/Torrents,/downloadsTV→14_TB_02/Torrents) would make locations clickable through to/filesand make "Set location" offer real destinations. Deliberately dropped from phase 1: the reference app has no such feature, so it would have been config nothing read. - Push instead of poll. Transmission RPC has no push channel, so
useTransmissionDatapolls every 5s. The sidecar could poll once and fan out over a WebSocket, which is both cheaper and what every other live surface in the platform already does. - Cross-surface links. Soulseek and
download-mediaboth land files in the same library; Transmission is the third door to it. Worth a think about whether they should know about each other at all.
Headscale
Gaps against headscale's own API, found while reading the app on 2026-08-05 — all closed the same day. Kept here for what the policy work turned up, which is not obvious from the code.
-
ACL policy.
/api/v1/policyGET/PUT behind/_officer/policy, with a plain HuJSON textarea that sends the text byte for byte and shows headscale's verdict verbatim (line and column included). Officer does not pre-validate: headscale owns the only parser that resolves groups, tags and hosts, and a second weaker one would disagree with the thing that actually enforces. The mode cannot be read. A file-backed policy is still served over GET; only a PUT refuses, with "update is disabled for modes other than 'database'". So the first save is what discovers writability, and a refusal becomes a persistent read-only banner. Verified live, not inferred. -
A node can be moved between users.
/api/v1/node/{id}/user, in the expanded node card next to the tag editor — both being the things that decide which policy rules apply to a node. -
User rename — was already shipped end to end (
/users/:id/rename,UsersView); this entry was stale when it was written. -
Polling —
useHeadscaleNodeshas hadrefetchInterval: 20_000all along; also stale. -
Device invites, platform side (
COMMS/OFFSCALE_INVITE_ENROLLMENT.md§5, built 2026-08-05). Authorize-new-device form, the one-time link with copy/share/QR, and the invite list with revoke —/_officer/enroll/invitesininvites.ts,InvitesView. The records are not Officer's. They proxy to the server's Officer Companion, because the joining phone has to claim without an Officer account and this sidecar is loopback-only behind our auth; the spec's own "an invite must work when the platform is down" argument says the same. Officer stores no invite and no claim token. Live on all four servers since 2026-08-05. The 401 that blocked it was a companion-side prefix parse (a Headscale key prefix is a fixed 12 chars and may contain-; they split on the first one); fixed upstream. Verified end to end against pastilhas-eu: create returns theofficer-offscale://join#…link, list shows the record, revoke flips it torevoked. The list envelope is not in the spec, sopickInvitestakes the body's array whatever it is keyed under. Four gaps were sent back to the spec author:keys.tshas no sub-day key TTL for the 5-minute claim key, "the sidecar must refuse plaintext" is unenforceable behind nginx,tailnetis not a headscale concept and has to be recorded on the invite, and/api/v1/enroll/*collides with headscale's own namespace.
Workspaces & Panels
Its own list — docs/workspace-panel-todo.md — because it is long and actively worked. Full
analysis behind it: COMMS/workspace-panel-framework-analysis-2026-08-07.md.
Headlines: the framework has no panel lifecycle (a panel can be destroyed but never told, which is
the real cause of the terminal orphan leak); three state-key families are silently dropped on every
write, orphaning a pty per reload; Running Shells has 404'd since 2026-07-31; there are zero
error boundaries anywhere in the repo; and dashboards.id is a global primary key fed by
slugify(name), so two members naming a dashboard the same thing collide.
Known bugs
-
bootstrap.tsrunsnpm install -gfor Pi on every boot.findPiPackageDirchecks stale paths and an outdated package name, soinstallPialways fires and floods the logs withEEXISTnoise. Should detect@earendil-works/pi-coding-agentat the real npm prefix. -
VNC mirror can orphan/duplicate x11vnc across sidecar restarts. FIXED 2026-08-01 in the Xvnc rewrite. The diagnosis was right and survived the move off mirroring: the running desktop is tracked in module-level state, so a sidecar restart forgot it while the server kept running orphaned, and
waitForPorttreated ANY listener on 5900 as success — so the next start reported success while the browser talked to the stale process. NowreclaimPortfrees 5900 (TERM, then KILL after 2s) before spawning, andwaitForPortalso fails if the process we spawned has exited, so a listener that is not ours can no longer be mistaken for a healthy start.
Infra (alpha)
-
Delete
/etc/systemd/system/officer-vnc.service. Hand-installed unit (not in this repo) that ranvncserver :1 -geometry 1920x1080 -localhost yes -fgwithRestart=on-failure, enabled at boot — it kept a whole parallel XFCE session alive on:1(283 MB, 166 tasks) independently of the platform, and silently respawned it whenever the display was killed. Obsolete now that the Desktop panel mirrors:0via x11vnc. Disabled 2026-07-15 (systemctl disable --now), but the unit file is still on disk. Nothing in the codebase recreates it and nothing documents it, so delete the file rather than leave a mystery service onesystemctl enableaway from returning. -
ufwblocks port 9010 on the LAN. Default deny incoming; only22/tcp,80/tcp,443/tcp, and everything ontailscale0are allowed — sohttp://192.168.47.196:9010is unreachable from the LAN while localhost and Tailscale work. Fix:sudo ufw allow from 192.168.47.0/24 to any port 9010 proto tcp
Backburner
- Music tagging feature (
/music, Mp3tag-inspired). v1 was scoped as a two-panel workspace — file browser left, table right, single "Load" context-menu item flattening audio files into the table. Discarded before completion; would needGET /file-browser/flatten-audio, auseFilesAPI.flattenAudiomethod, and the Music app panel rebuilt from scratch.