The root held four Markdown files that were not in any git repo and were being read as current. CLAUDE_SIDECAR_ISOLATION.md is the one that prompted this: it describes, in the present tense, an agent that dies whenever officer restarts. That was true when it was written and has not been true for two days. Rather than delete analysis that version control was not holding, the two substantial ones moved into platform/docs/ with headers that say what has since happened: - claude-sidecar-isolation.md — stages 0-2 are done and running (R1, R2, R4, R5 all satisfied); stages 3-5 are the only live part. - sidecar-audit-2026-07.md — a snapshot audit, largely executed. Email, pty, music and vnc have been done since; claude, opencode and the cross-cutting notes are still open. The vault stays off-limits. MUSIC_IMAGES_SPEC.md is deleted outright: the sidecar serves /image and /poster, so the spec is the feature. The workspace root now holds one Markdown file, CLAUDE.md, which is where cross-cutting operational reality belongs. Also corrected, in the same pass: - root CLAUDE.md listed email as "the big one, and untouched" and pty stage 4 as outstanding; both are done. It now names what actually remains (claude stages 3-5, the terminal orphan leak) and what landed. - docs/sidecar-topology.md said "nothing built yet". Two sidecars now serve their own transport; what has NOT happened is the part the document is about — fixed ports, the shared table, .env toggles — so it says exactly that rather than implying the design is underway. Migration order updated: pty and email went first, not music. - docs/navigation-audit.md is cited as authoritative but still planned work on Projects, a feature since deleted. A status header marks H3 void, H1/H2 done and H4 the one open item, so nobody follows it into a directory that no longer exists. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
93 KiB
Historical — a snapshot audit, largely executed. Two-pass audit from 2026-07-30. Most of what it recommends has since been done: email moved into its sidecar entirely (store, routes, accounts, resync and both syncs), pty became a byte relay onto the sidecar's own listener, music's cliamp pipeline moved out of the platform, vnc stopped reading the owner's password, and every HTTP sidecar now shares one proxy factory. Still open: claude (stages 3–5), opencode, the frontend-speaks-slskd smell, and the cross-cutting notes on the protocol and the registry. The vault is off-limits by standing instruction. Read it for the reasoning and the line counts, not as a description of today.
Sidecar architecture — the rule, and where we currently break it
Written 2026-07-30, after an audit of the Soulseek work. This is a findings document, not a plan: nothing here has been changed. Discussion pending.
The rule
The main Officer process is a thin auth proxy, forever. Each sidecar owns everything related to its job. The custom UI exists so the upstream service (slskd) stays unforked while everything custom is implemented on top of it.
Three consequences worth stating explicitly, because the audit turned on the third one:
- The platform holds no credentials for the upstream service, and no knowledge of its API.
- The platform's router for a sidecar has no routes of its own — it authenticates, injects
X-Officer-User, forwards, and returns the response untouched. - The sidecar, not the browser, owns the upstream contract. A thin proxy that forwards raw upstream calls to a frontend that knows the upstream's URLs and JSON shapes has moved the coupling rather than removed it. This is the part we are currently getting wrong.
What is compliant (verified)
src/servers/api/slskd/ is 70 lines total and does exactly the two things it should:
| File | Lines | Role |
|---|---|---|
router.ts |
51 | all('/*') catch-all. Forwards subpath + query + body, injects X-Officer-User, streams the response back. No routes of its own. |
sidecar-server.ts |
19 | Remembers the port the sidecar reports on connect (slskd:server). Nothing else. |
SLSKD_URL/SLSKD_API_KEYare read in exactly one file:src/servers/sidecar/slskd/upstream.ts. The platform never sees either.hono.tscontains 3 slskd lines: two imports andprotectedRouter.route('/slskd', slskdRouter).protocol.tscontains 1: theslskd:serverport message.- No platform-server file imports any
soulseek*query fromofficerdb.
Today's three commits (8032c8b, b7b91a2, dea9ee2) touched zero platform-server files.
Not a violation, but worth naming
The soulseek_* tables live in the shared officer_db package (schema/soulseek.ts,
queries/soulseek.ts) rather than in the sidecar. Only the sidecar reads them — this was a
deliberate call (one database, schema isolated in its own file, soulseek_ prefix) and it stands.
The cost to remember: bun db:push diffs the whole schema, which is why soulseek DDL is
hand-applied.
The smell: the frontend speaks slskd
37 raw /slskd/api/v0/… calls from React, against 10 /slskd/_officer/… calls.
| File | Raw slskd calls |
|---|---|
SoulseekTransfers.tsx |
8 |
SoulseekRooms.tsx |
6 |
SoulseekChat.tsx |
5 |
SoulseekDashboard.tsx |
4 |
SearchView.tsx |
4 |
SoulseekSystem.tsx |
3 |
SearchResults.tsx |
3 |
useSoulseekUser.ts |
2 |
SoulseekUploads.tsx |
2 |
The proxy stays thin, so rule (1) and (2) hold. Rule (3) does not: shared.ts defines slskd's wire
types (SlskdUserStatus, SlskdUserInfo, SlskdTransferUser, …), and the panels know slskd's URL
shapes, field names and failure modes. If slskd renames a field, the break lands in React.
Three places where it's not just a pass-through call but domain logic that belongs in the sidecar:
1. useSoulseekUser.ts — introduced today (b7b91a2)
Fans out to /users/<u>/status and /users/<u>/info, then reconciles in the browser: both
rejected → "couldn't reach them"; one rejected → show the half we got. That "what does a partially
reachable peer look like" decision is Soulseek domain knowledge living in a React hook.
Target shape: GET /_officer/users/<u> in the sidecar does both calls, merges, and returns one
Officer-shaped peer. The hook becomes a single fetch that doesn't know slskd exists.
2. SoulseekDashboard.tsx
Four parallel calls (/application, /transfers/downloads, /transfers/uploads, /searches)
aggregated client-side into dashboard figures. The aggregation is the product; slskd just supplies
inputs. Belongs behind one /_officer/dashboard.
3. SoulseekTransfers.tsx
Eight call sites, including state rollups across users and multi-delete fan-outs
(Promise.all over per-file DELETEs). Bulk operations over an upstream that has no bulk endpoint
is precisely the "custom on top of unforked slskd" the sidecar exists for — and doing it from the
browser means a closed tab is a half-finished operation.
The remaining panels (Rooms, Chat, SearchView, SearchResults, Uploads, System) are
mostly genuine 1:1 pass-throughs. They still leak slskd's shapes into the UI, but there's no
composition logic to move — lower priority, and arguably fine to leave until they grow one.
Where the line actually is
Worth agreeing on before any refactor, since "everything through /_officer/*" and "proxy raw
where it's 1:1" are both defensible:
- Sidecar route when the answer involves more than one upstream call, any merging or reconciliation, any Officer-only data (Postgres), any bulk operation, or any decision about what a partial failure means.
- Raw proxy when it is one upstream call, forwarded, rendered — no composition of any kind.
Under that line, the three files above are the work. The other six are a naming/typing question
(does the UI import SlskdRoom, or an Officer type?) rather than an architecture one.
Reference: the compliant pieces of the Soulseek sidecar
src/servers/sidecar/slskd/ — what "the sidecar owns its job" already looks like:
| File | Role |
|---|---|
index.ts |
Reverse proxy to slskd on a random loopback port; documents the whole /api/slskd/* contract; reports its port to the platform. |
upstream.ts |
The only holder of SLSKD_URL / SLSKD_API_KEY. |
officer.ts |
The /_officer/* routes — favourites, browse snapshots, tree levels, filtered search, downloads. Features slskd has no concept of. |
browse.ts |
Multi-minute background share-tree fetch, tree built at ingest. Outlives any request. |
download.ts |
Expands a browsed folder into a file list from cache and enqueues it. MAX_ENQUEUE guard. |
Other sidecars for comparison: claude, email, music, opencode, vault, vnc.
Pass 1 — backend / API code
One section per sidecar: what logic still runs in the main officer process. Frontend is deliberately
excluded here; it gets its own pass below. Every claim carries a file:line so nothing has to be
re-derived later.
The eight sidecars, from ecosystem.config.cjs:
| PM2 process | Entry point |
|---|---|
officer-claude |
src/servers/sidecar/claude/index.ts |
officer-opencode |
src/servers/sidecar/opencode/index.ts |
officer-email |
src/servers/sidecar/email/index.ts |
officer-pty |
src/servers/api/terminal/pty-sidecar.mjs ← note the path |
officer-vnc |
src/servers/sidecar/vnc/index.ts |
officer-music |
src/servers/sidecar/music/index.ts |
officer-vault |
src/servers/sidecar/vault/index.ts |
officer-slskd |
src/servers/sidecar/slskd/index.ts |
music — partially compliant
/api/music/* is a near-exact clone of the slskd proxy and is clean. The violation is the
cliamp audio subsystem: the same domain — playing local audio to the browser — implemented
entirely in the main process, with no sidecar owning any of it.
| Surface | Lines |
|---|---|
Compliant proxy: api/music/router.ts + api/music/sidecar-server.ts |
88 |
hono.ts (3) + protocol.ts (1) |
4 |
api/cliamp/websocket.ts |
201 |
api/cliamp/audio-ws.ts |
91 |
server.tsx PulseAudio bootstrap |
46 |
api/cliamp/asoundrc + server.tsx cliamp wiring |
18 |
| Audio domain in the main process | ~356 |
-
cliamp player process management —
api/cliamp/websocket.ts:1-201. Locates thecliampbinary by probingBun.whichplus three GOPATH candidates (:48-66), resolves and traversal-validates the requested file against the owner's real home (:40-46,:85-98), shell-escapes it (:68,:102), then spawnsscript -qfc '<cliamp> <file>' /dev/nullto fake a PTY (:106-113) withPULSE_SINK: 'virtual_out'andALSA_CONFIG_PATHinjected. Pumps stdout/stderr into JSON frames (:124-160), forwards{type:'input'}to stdin (:173-185), kills the child on close (:187-198). Child processes are held in a module-levelMap(:22). Belongs insidecar/music/, which already runs its own loopback HTTP server (sidecar/music/index.ts:140). Obstacle: a browser-held WebSocket with bidirectional keystroke traffic — but the relay pattern already exists twice (server.tsx:164-228for dev-server,server.tsx:323-326for vault). -
PulseAudio host-daemon bootstrap —
server.tsx:391-436. A startup IIFE that locatespulseaudio/pactl, runspulseaudio --start -Dif the daemon is down (:401-411), then grepspactl list short sinksand loadsmodule-null-sink sink_name=virtual_outif absent (:414-435). Runs unconditionally at every boot even if nobody opens the player. Belongs in the music sidecar's startup. Obstacle: none technical — same host,pactlworks identically. Must move together with (1) and (3), since the sink must exist before they start. -
Host audio capture → browser PCM —
api/cliamp/audio-ws.ts:1-91. Spawnsparec --format=s16le --rate=44100 --channels=2 -d virtual_out.monitor(:28-33) and pushes each chunk to the browser as a binary frame (:44-73). Hardcoded format, sample rate, channel count and monitor device name — pipeline domain knowledge. Obstacle: continuous binary PCM, so a relay hop costs a copy per chunk. -
ALSA config shipped inside the API tree —
api/cliamp/asoundrc:1-9, passed viaALSA_CONFIG_PATH(websocket.ts:8,:112). Upstream config in the thin-proxy process. Moves for free — it's resolved by__dirname. -
Music-domain param in the WS envelope —
server.tsx:253,:265,WSData.filesat:53.upgradeWsplumbs afilesquery param that exists only for this feature. The JWT verify + blacklist check inupgradeWs(:231-272) is legitimate platform work; the feature-specific parameter is not. -
Route names leaking into the proxy —
api/music/router.ts:29-36. The catch-all special-cases/reindexand/reindex/streamby name to callserver.timeout(req, 1800). Minor, but the slskd reference router is uniform for every path. Simple fix: extend the timeout for the whole prefix. -
Done — the scope list is generic now (2026-07-30,
17b1da2) —_middlewares/origin-validation.ts. The finding was that the music app's origin was allowlisted by hand and its scope named the feature, withNON_OWNER_PATHS = ['/api/auth', '/api/music']backstopping a music-only account class. None of it belonged in the sidecar — this is authorisation, i.e. the platform's actual job, and an origin is not a credential — so the only cleanup available was making the scope list generic instead of naming music. That is what landed:Any
OFFICER_<APP>_ORIGINin the environment is an app origin, and its rule is derived from its own name —/api/auth(it has to sign in) plus/api/<slug>. SoOFFICER_MUSIC_ORIGIN(renamed fromMUSIC_APP_ORIGIN) gets/api/musicwithout being mentioned in code, and adding an app is adding an env var. Two declared exceptions, because they are not that convention:OWNER_ONLY_APPS(APP— the main app is the whole platform rather than one feature, so full access but owner-only) andAPP_SCOPE_OVERRIDES(TAIL→/api/vpn, the one app whose name and API surface differ).NON_OWNER_PATHSstays hand-written, and should not be mistaken for the music app's rule even though the value matches: it is the account backstop, enforced whatever Origin a caller claims or omits, and it is the airtight half of the pair. The origin rules are defence in depth on top of it.
Verified clean: no music credential or library path in the main process (MUSIC_ROOT exists only in
sidecar/music/indexer.ts:24); all range/206/Content-Range handling is sidecar-side
(stream-audio.ts:77-91); queries/music.ts (231 lines) has exactly one consumer,
sidecar/music/index.ts:25-39.
Adjacent, flagged for the file-browser pass rather than counted here:
api/file-browser/router.ts does real audio work in the main process — /audio-meta shells out to
ffprobe twice for ID3 tags and USLT/SYLT lyrics sniffing (:459-523), /extract-audio transcodes
video→mp3 with ffmpeg -codec:a libmp3lame (:909-942), /audio-tracks probes streams (:407-455),
ensureAudioRemux remuxes for range-seekable playback (:80-132). Same kind of work the music
sidecar owns, but it belongs to the file-browser contract.
vault — partially compliant, and the placement is security-relevant
The transport proxy is right; the platform owns the entire Vaultwarden auth/session/key-custody domain. This is the worst offender of the eight, and the one where placement has real consequences.
| Surface | Lines |
|---|---|
api/vault/router.ts |
169 |
api/vault/websocket.ts |
164 |
api/vault/broker.ts |
79 |
api/vault/token-store.ts |
34 |
api/vault/proxy-util.ts |
29 |
api/vault/sidecar-server.ts |
24 |
api/vault/ total |
499 |
hono.ts (7) + server.tsx (8) + protocol.ts (2) + auth handlers (8) |
25 |
officerdb vault layer the platform calls: queries/vault.ts + crypto.ts + schema/vault.ts |
176 |
| Total | ~702 (~525 excluding the shared DB package) |
For scale: the sidecar itself is 295 lines and is a genuine dumb pass-through
(sidecar/vault/index.ts:153-183 streams bodies verbatim; upstream.ts:12-22 is the only reader of
VAULTWARDEN_URL).
Two structural notes before the findings:
api/vault/router.ts:123is anall('/*')catch-all, but it is not thin — it replaces theAuthorizationheader with a platform-held upstream credential (:137-141) and implements 401-refresh-retry (:158-165).- It does not inject
X-Officer-User(contrastapi/slskd/router.ts:34,api/music/router.ts:50). The vault sidecar receives no user identity at all — only a bearer token. Everything below follows from that one inversion.
- Vaultwarden token broker in the main process —
api/vault/broker.ts:1-79+api/vault/router.ts:52-104.broker.ts:46-57builds an OAuthgrant_type=passwordform (username=email,password=authHash) and POSTs it to/identity/connect/token(:15-20);:60-66does therefresh_tokengrant.router.ts:52-104is a hand-writtenPOST /session/loginthat parses the body, callspasswordGrant, extracts the token pair, persists it and assembles the crypto-material response. Pure Vaultwarden protocol knowledge, including its version-dependent casing quirks (broker.ts:69-73) and inconsistent error-message locations (:27-31). Security-relevant: the master-password-derivedauthHash(router.ts:57,broker.ts:50) transits main-process memory, and the main process is what mints and holds the upstream token set. "Platform holds no upstream credentials" is violated directly here. - Token custody + refresh in the main process —
api/vault/token-store.ts:1-34, called atrouter.ts:137,:158-165,websocket.ts:73. Loads the decrypted token set from Postgres, applies a 60s expiry skew (:8,:30), re-derives and persists a new pair on expiry (:11-24). This is the direct analogue of the slskd API key — which the platform deliberately never sees. Security-relevant: plaintext access + refresh tokens exist as JS strings in the main process on every proxied request. - Notifications WebSocket proxied twice, with token injection —
api/vault/websocket.ts:1-164.injectToken(:45-52) rewrites the SignalR query string: dropstoken, setsaccess_token=<vw token>.open(:55-118) verifies the platform JWT, fetches the upstream token, dialsws://127.0.0.1:<sidecarPort>and runs a full buffered bidirectional pipe — which the sidecar already implements (sidecar/vault/index.ts:45-114,:143-151). Frames are relayed twice. Obstacle: Bun requires a synchronous upgrade, hence the deferred validation at:60-70; that pattern stays, the token lookup at:73should not. - The platform is the vault's key escrow —
api/vault/router.ts:107-120.PUT /unlock-keypersists awrappedKey(:111);GET /unlock-keyhands it back to any owner session (:117-119). This is the protector key that unwraps the on-device wrapped user key — i.e. what opens the vault without the master password (schema/vault.ts:22-25). Security-relevant, sharpest one: combined with (2), one compromised main process yields both the transport token and the key material that decrypts the vault, and the read side is a plain endpoint gated only by the platform session. - Vault at-rest crypto executes in the main process —
officerdb/src/crypto.ts:1-41, called fromqueries/vault.ts:22-23,34-35,67-68,83,88. Derives an AES-256-GCM key asSHA-256(VAULT_STORE_KEY)(:12-20) and runscreateCipheriv/createDecipheriv(:23-39). Because the vault router importsofficerdb(router.ts:10,token-store.ts:1), all of this runs insideofficer. Obstacle:crypto.tslives in the shared package, so it is importable from anywhere; moving it means moving the vault queries out of the shared package or enforcing a sidecar-only import boundary. No config obstacle — both processes read the same.env. - Vault tables are read/written by the platform, not the sidecar —
queries/vault.ts:18-101,schema/vault.ts:10-32, exported globally atofficerdb/src/index.ts:130-138. Every current caller is main-process:router.ts:78,111,117,159,token-store.ts:17,28, plus (7). The vault sidecar imports no DB module at all. Exact inverse of the intended ownership. - Auth flows reach into vault storage directly —
api/auth/signout.ts:10,revoke-handler.ts:18-19,panic-handler.ts:15-16. Signout deletes the token row; distress and panic also burn the protector key. The policy is platform-level; the mechanism — direct DELETEs against the sidecar's tables — is not. All three are already best-effort.catch(() => {}), so failure semantics wouldn't worsen behind a sidecar call. - Dead weight —
router.ts:38-48is a hand-writtenGET /_healthpassthrough the catch-all would cover for free.proxy-util.ts:1-29is a byte-for-byte duplicate of the sidecar'sstripHopByHop/redactPath(sidecar/vault/upstream.ts:33-58), identicalHOP_BY_HOPset andREDACTregex. The redaction exists only because the main process handles token-bearing query strings — fix (2) and it's unnecessary. - Mounted outside the protected tree —
hono.ts:73-77.route('/api/vault', …)sits outsideprotectedRouter, so the router re-implements its own stack (router.ts:31-33:originMiddleware,userMiddleware,ownerGate). Real constraint, probably why: it deliberately avoidsbodyParser()so bodies stream (router.ts:14), andprotectedRouterwould inherit it fromhono.ts:88and buffer vault attachments. - Stale comments on a security boundary —
hono.ts:73-76andorigin-validation.ts:36-39,61-64both claim vault requests "carry their own Bitwarden bearer token, not a platform session JWT" and thatuserMiddlewarewould 401 them. Untrue sincerouter.ts:32-33requires a valid platform JWT and owner status on every request. Also,VAULT_AUTH_SPEC.md(cited atrouter.ts:12,schema/vault.ts:4) andBITWARDEN_SIDECAR_PROMPT.md(cited atsidecar/vault/upstream.ts:3) do not exist in the repo. Not logic, but exactly the drift that makes someone loosen a gate by mistake.
Verified clean: protocol.ts:61-62 carries only {type:'vault:server', port} — no secrets cross the
registration socket. No other platform code and no other sidecar reads vault secrets — grepped
every officerdb vault export across src/servers and src/databases; the only consumers are
api/vault/* and the three auth handlers. No MCP tool, queue job, or agent path pulls vault items.
User-key derivation is genuinely client-side (the platform only relays Kdf* params at
router.ts:96-101) — the one credential decision that is correctly placed.
Shortest path to compliance (inferred, not attempted): move broker.ts, token-store.ts,
/session/login, /unlock-key, the vault queries and crypto.ts into sidecar/vault/; have the
router inject X-Officer-User instead of Authorization; reduce websocket.ts to
origin-check + verify + upgrade + dumb pipe; delete /_health and proxy-util.ts; replace the three
auth-handler DB calls with a sidecar call. Result: a slskd-sized router plus sidecar-server.ts, and
no plaintext token or key buffer anywhere in officer.
claude — not compliant. ~2,700–3,050 platform lines against slskd's 70
The largest violation after email, and the one with the worst consequences, because it is the reason a
pm2 restart officer kills a running agent. The process-topology half of this has its own document
— CLAUDE_SIDECAR_ISOLATION.md. This section is the inventory of domain logic; that one is the fix
for survivability. They overlap deliberately.
There is no /api/claude mount, no proxy router, and no X-Officer-User anywhere on this path.
Nothing here is shaped like slskd. The platform does not forward to the claude sidecar; it drives it,
over a typed RPC vocabulary, and interprets everything that comes back.
A structural fact worth stating before the list, because it inverts the usual reading: the sidecar
imports its own event type from the platform. sidecar/protocol.ts:1 and
sidecar/claude/user-instance.ts:121 both import ChatEvent from ../../api/chat/types. The
dependency arrow points the wrong way — the sidecar is a client of the platform's domain model rather
than the owner of it. Every other item below is downstream of that.
api/chat/websocket.ts:1-636— the turn loop. The single biggest concentration. The default model is decided here (DEFAULT_MODEL = 'claude-code',:16), the harness is chosen by string test (isClaudeModel,:20), the prompt is constructed here (:336-338), cwd is resolved here (:42-82), and:407-435encodes the first-turn-vs-subsequent-turn distinction (whether to pass a resume id) — a fact about the Claude CLI's contract living in the platform.:564-589owns stop semantics.:443-518is shared with opencode.api/chat/websocket.ts:168-312— a ChatEvent state machine. 145 lines interpreting the sidecar's event stream: mutating tool-call records in place (:247-254), accruing cost (:279-282), and persisting viaappendChatEvent(:103-116) with replay at:612-629. This is the machinery that makes a restart lossy — it is the durable writer, and it sits on the far side of the socket from the process producing the events.api/chat/claude-sessions.ts:1-361— a reimplementation of Claude's transcript format. The platform reads and writes~/.claude/projects/<slug>/<uuid>.jsonldirectly: the slug encoding (:39), the entry schema (:69-78), content-block decoding (:143-227), listing (:350-361), delete-by-unlink (:253-258), a 32KBreadSyncplus a"cwd":"…"regex to recover a session's directory (:294-306), and — the sharpest example — rename implemented by appending a{type:'summary'}entry and reconstructingleafUuid(:265-287). The platform is writing into another program's private on-disk format. If the CLI changes that format,officerbreaks.api/tasks/pipeline-executor.ts:98-249, 585-586— a second, independent interpreter. Its ownrefreshProxyToken()POSTing to127.0.0.1:${ANTHROPIC_PROXY_PORT}/refresh(:98-105), its own model branch (:126-129), its own ChatEvent interpretation (:151-201), its own prompt builder (:257-268), and:585-586silently coerces any model toclaude-code. Two divergent implementations of the same protocol is the cost of not having a sidecar boundary.sidecar-registry.ts:198-274— the platform spawns the agent.ensureClaudeSidecar/spawnAndWaitForRegistration: a per-emailBun.spawnofuser-instance.tswithstdout: 'inherit', stderr: 'inherit'(:240-241), aclaudeProcsMap, aclaudeSpawnWaitersMap, a 15s timeout and a 50ms registration poll (:259-267). Plus the claude verbs at:306-358and a broadcast fallback at:339-346.:88-90usescapabilities.includes('proxy')as a stand-in for "is this the claude sidecar", which is only true by accident of naming.generate-container-context.ts:135-182(+:50-133) — the platform writes the CLI's config. It authors~/.claude/settings.json: aStophook curlinglocalhost:5000/api/hooks/claude-done(:144-153),defaultMode: 'bypassPermissions'andskipDangerousModePermissionPrompt: true(:155-156), and pre-seededhasTrustDialogAccepted(:163-179). Called fromusers/provision.ts:28-38. Two notes: the hook points at the platform, so it fails during exactly the restart window that matters; and the permission posture is a deliberate documented choice (platform/CLAUDE.md: agents run unsandboxed as the owner) that is being implemented in the wrong process, not a mistake.api/activity/router.ts:1-191— the platform walks the agent's scratch tree. Reads/tmp/claude-<uid>/<encoded-cwd>/tasks/<id>.output(:24-61, keyed onstartsWith('claude-')at:34) and tails it over SSE (:116-191). Another private layout the platform has learned. (/announce,:103-113, is unrelated and fine.)api/chat/session-manager.ts:1-152— a mirror of sidecar state in the platform (:29-32), with its own GC (:126-138) and teardown (:83-88). Two copies of the same truth.api/chat/chat.ts:1-123— session CRUD (:33-85) plus hardcoded provider names (:88-97). (/chat/stt,:100-123, is Whisper — a different domain that happens to live here.)api/chat/list-models.ts:5-9— the model catalogue is hardcoded in the platform:claude-code/opus|sonnet|haikuwith context windows, max tokens and reasoning flags. This is the sidecar's knowledge by definition; it changes when the CLI changes, not when Officer does.api/server-settings/claude-code.ts:1-80— installing and authenticating the CLI from the platform:curl … install.sh | bash(:24),claude auth login(:45-47),auth status(:56-61),--version(:9, :71).channels/send-claude-code.ts:1-68— a third execution path, running the SDK in-process, with a subscription-lifetime rule at:53-58.sidecar/protocol.ts:14-19, 34-43, 68-103— nine claude message types in the shared protocol, includingClaudeState(:70-73, which exposesproxySecretand the whole session map) andClaudeSpawnParams(:77-96). Domain vocabulary in what should be transport.- Smaller, still real:
channels/send-and-await.ts:6, 53, 75-78;hono.ts:79-86— the/api/hooks/claude-doneendpoint is unauthenticated (deliberate, since the hook has no credential, but it means an unauthenticated local caller can nudge chat state);hono.ts:119;api/chat/retention.ts:1-22(started frombootstrap.ts:17);queries/chat-events.ts:1-29(read only by the platform);mcp-tool-server.ts:1-296;api/chat/logger.ts:1-62.
Dead code on this path, verified unimported (≈193 lines): api/task-logger.ts:1-101; and
api/anthropic-proxy.ts:1-92, which reads ~/.claude/.credentials.json (:6, 18-27) and mints
sk-ant-api03-<uuid> keys (:10) — the live copy is sidecar/claude/proxy.ts:135. A stale second
implementation of the credential path is worth deleting on security grounds alone, not just tidiness.
What the sidecar already has right: the Anthropic proxy genuinely lives in the PM2-managed sidecar
(sidecar/claude/index.ts:20), so the platform never holds an API key at rest, and ANTHROPIC_BASE_URL
points at the sidecar (sidecar-registry.ts:234). The credential path is roughly correct. It is the
process topology, the transport direction and the domain logic that are not.
email — not compliant, and it is the exact inverse of the pattern
The worst of the eight by volume, and the only one where the arrow points backwards end to end:
≈3,238 platform lines (2,875 of them in seven files) against a 314-line sidecar — and the
sidecar imports platform code back out (sidecar/email/email-idle.ts:3 imports
../../api/email/resync). There is no proxy router, no email:server port event, and no forwarding of
any kind. emailRouter implements 18 concrete endpoints itself.
Read plainly: the sidecar is a cron/IDLE trigger, and the platform is the mail client.
api/email/email-db.ts:1-746— the entire mail store lives in the platform. SQLite schema (:9-55), migrations with athread_idbackfill (:167-217), an FTS5 index (:223-251), a hand-written Gmail-style query parser (:253-349), threading (:81-132), and — the part that is unambiguously protocol knowledge — RFC-2047 decoding, quoted-printable, base64, charset conversion, multipart walking and attachment extraction (:496-746).openEmailDb(:134-154) carries a documented race at:138-139.queue/handlers/gmail-sync.ts:1-712— Gmail sync runs in the main process. Gmail REST calls (:103-213), label mapping (:65-96,:329-396), an IMAP first-sync path (:398-546), OAuth refresh (:586-608). It runs inofficerbecauseserver.tsx:385callsinitQueue()→queue/init.ts:6→handlers/index.ts:1-2. So a full mailbox sync competes with request serving, and dies on restart.queue/handlers/email-sync.ts:1-381— the IMAP sync, same placement.imapflow(:118), a label map (:48-74), UIDVALIDITY tracking, a ten-attempt reconnect loop (:134-335), a Message-Id hash (:40-44), OAuth refresh (:78-107), and dock mutation (:353-377). A ten-attempt reconnect loop is precisely the kind of long-lived stateful work sidecars exist for.api/email/email.ts:1-414— sending and reading. SMTP send (:16-69) where the SMTP host is derived by string-munging the IMAP host (:24); an SSE hub (:94-146); conversation collapsing (:167-202); thread fetch (:246-285); row shaping (:204-228); attachment extraction (:287-318); contacts (:72-92); stats (:362-414). Two unparameterised SQL interpolations:labels LIKE '%${folder}%'at:174and:366. Thefoldervalue reaches those lines from request input. Worth checking before anything else in this section — it is the one item here that is a defect rather than a placement problem.api/email/resync.ts:1-309— shared mutable coordination across a process boundary that cannot work.gmailResync(:43-63),imapResync(:148-264),resolveImapAuth(:118-146),refreshCredentials(:21-41), andperformResync(:276-284) which coalesces concurrent resyncs through an in-process Map. It is imported by bothsidecar/email/email-cron.ts:2andemail-idle.ts:3and byaccounts.ts:159— i.e. by two different processes. Each gets its own copy of the Map, so the coalescing silently does nothing across the boundary. This is what "importing platform code back out" costs.api/email/accounts.ts:1-259— account setup does live IMAP. Validation by real connection on create (:87-121), provider dispatch (:180),resolveAuth(:231-259), stale-status reconciliation (:38-85), and raw credentials written into queue job metadata (:198,:200) — so mail passwords land in a Postgres job row.- Three channel handlers duplicate ~218 lines of email logic each:
channels/telegram/handler.ts:48-119(:53,:87, enqueue at:65),channels/discord/handler.ts:47-122(:53,:89),channels/whatsapp/handler.ts:52-121(:57,:91). Same work, three times, none of it in the sidecar. api/integrations/google-auth.ts:1-61+integrations.ts:162-194— a second Gmail client. Token exchange (:6-33), refresh-and-persist (:38-61), and atintegrations.ts:162-194an independent Gmail REST proxy.:15-19requests thehttps://mail.google.com/scope — full mailbox access — and:307-317mutates the dock.api/chat/websocket.ts:57-70—resolveEmailCwdmkdirs email account directories from inside the chat socket. Cross-domain reach: the chat path owns email's filesystem layout.data-path.ts:44-50— the platform owns the mail store's paths, with a second consumer atsidecar/claude/user-instance.ts:40, 75(theemail_dbMCP tool). So three processes agree on a layout by convention rather than by asking one owner.- Glue (routing only, would survive a proxy):
hono.ts:34+:114;server.tsx:20+:85-89;protocol.ts:50-51carries onlyemail:new, which is correctly transport-shaped. src/servers/sidecar/email-cron.ts— 92 dead lines, imported by nothing (the live one issidecar/email/email-cron.ts).
Shortest path (inferred): this one is a rewrite, not a move. The realistic first step is not
relocating email-db.ts — it is deleting the duplicate clients (items 7 and 8) and moving the two
queue handlers (items 2 and 3) into the sidecar so sync stops dying with officer. The store itself
can follow later, behind a proxy router.
opencode — not compliant. ≈792 platform lines (≈1,424 counting the credential file)
Structurally the same failure as claude, at a third of the size — and with the largest proportion of dead code of any sidecar: roughly 330 lines that can be deleted outright, because the sidecar already does the same job. That makes this the cheapest of the non-compliant surfaces to improve.
api/chat/opencode/client.ts:1-208— a full HTTP + SSE client in the platform, including a hand-rolled SSE parser (:48-96), REST wrappers (:98-167), wire shapes (:171-195), themetadata.officerconvention (:179-182), and a comment pinning it to "opencode 1.17.9" (:170) — a version dependency inofficer's source.:16-96and:108-139are dead.api/chat/opencode/event-mapper.ts:1-140— entirely dead. The sidecar already maps events atsidecar/opencode/runner.ts:150-205. This is a stale duplicate of a sidecar concern.api/server-settings/chat-providers.ts:1-632— provider credentials handled in the platform. Reads and writes~/.pi/agent/auth.json(:10,:334-346) andmodels.json(:11,:61-73), exposes API keys over GET/PUT (:384-406), and callsapi.anthropic.com,api.openai.comandopencode.ai/zen/v1/modelswith raw keys (:444-471).PROVIDERSat:353-367;invalidateModelCache()at:284, 293, 328, 404;:474-551is stalepiinstall logic. Open question before moving any of it:AGENT_CONFIG_DIR = ~/.pi/agent(data-path.ts:23-24) may be dead config — I did not verify that the current opencode binary reads that path at all. If it doesn't, a large part of this file is elaborate no-op and should be deleted rather than moved.api/chat/opencode-sessions.ts:1-113— transcript reconstruction (:50-82), filtering onmetadata.officer.cwd(:21), epoch→ISO conversion (:29-30), and a synthesisedopencode/<modelID>model string (:84). Same shape asclaude-sessions.ts: the platform reading another program's session store.api/chat/websocket.ts:443-518(+:6, 18-20, 345-348, 570-584) —handleOpenCodeChat, including deriving a session title from the prompt (:490) and selecting the harness by string test.channels/send-opencode.ts:29-66— the terminal-event set (:33-37) and a resume policy keyed on theses_id prefix (:42-45). Protocol knowledge encoded as a string prefix, in the platform.api/chat/list-models.ts:2, 11-59— fetches/config/providersand then invents capability metadata for the results (:42-45).api/chat/chat.ts:13-19, 44, 56-57, 68, 80-81, 91— CRUD dispatch onisOpenCodeSessionId(opencode-sessions.ts:112, astartsWith('ses_')test).sidecar-registry.ts:360-392— typed opencode RPC, same anti-pattern as the claude verbs.sidecar/protocol.ts:20-22, 52-58, 105-113— CLI flag mapping in the shared protocol (--dir,--model,--session). The platform's wire format encodes the sidecar's command line.- More dead code:
api/chat/opencode/server-manager.ts:7-23(isServerHealthy, unused);api/chat/opencode/state.ts:13-15(clearOpenCodeSession, unused).state.ts:1-15otherwise holds a sessionKey→ses_Map — sidecar state mirrored in the platform again.
The one compliant piece: api/chat/opencode/sidecar-server.ts:12-26 memoises the port the sidecar
reports — the slskd sidecar-server.ts pattern, already present and working. The proxy that should sit
on top of it was never written. (:19-21 adds a session subscription, which is domain logic that
doesn't belong in a port memo.)
Verified clean: no OPENCODE_* env var, no binary path, and no CLI spawn in the main process — the
process boundary itself is right here, unlike claude. It is only the data and protocol knowledge that
leaked.
pty — not compliant. 202 platform lines (228 with protocol) against a 73-line reference
The smallest violation in absolute terms and the least compliant in proportion: essentially the entire platform-side file is sidecar logic. It is also the most tractable, because the fix template already exists in the same codebase.
api/terminal/websocket.ts:1-169— the whole file. Broken out::59-68— the platform builds thePtyInitConfig:process.env.SHELL ?? '/bin/zsh',args: ['-i'], andhost: truehardcoded at:61. That last one means the sidecar's own sandbox branch (pty-sidecar.mjs:138-151) is unreachable — dead code kept alive only by a constant on the wrong side of the boundary.:38-43—resolveCwdtilde expansion in the platform.:71-89— per-session fan-out. The platform subscribes to a globalpty:output/pty:exitstream, filters bymsg.sessionId === sessionId, and rewraps each frame. This is the hard part of any fix, and the template already exists atserver.tsx:164-228(devServerWebsocket— a real byte relay).:106-143— inbound translation, including:135synthesizing a shell command:`cd ${JSON.stringify(msg.path)}\r`. The platform is typing into the user's shell.:145-153— detach-not-kill on disconnect. Correct policy, and worth preserving: this is why terminals survive a browser reload. Note thatpty:closehas no sender anywhere in the platform, so there is currently no kill path at all.:51-54,:97-103— ANSI-framed error text composed in the platform.:23, 25-28, 30-36— a duplicated session Map,nextIdandsendOutput.:158-169—broadcastPanelRefresh, a Claude concern parked in the terminal module; its only caller ishono.ts:83(theclaude-donehook).
- A placement anomaly worth fixing on its own:
ecosystem.config.cjs:27-32is the only sidecar outsidesrc/servers/sidecar/, the only.mjs, and the only one run bynoderather thanbun. It has zero relative imports (pty-sidecar.mjs:13-19, 31) and does not usesidecar/connect.ts— it carries 66 duplicated lines (:232-297) including a byte-identical backoff table (:37vsconnect.ts:22). Its types are JSDoc (:39), soprotocol.ts:145-156is unenforced against it. The actual blocker to moving it is mundane: siblingtemplates/files on disk (:27-29, 61, 66, 71—.zshrc,.tmux.conf,starship-officer.toml, and an unused.zshenv). So agit mv, not a rewrite. (Inferred: the.mjs/node choice is probably a node-pty native-addon workaround — corroborated by the comment atapi/cliamp/websocket.ts:100.) - Every PTY byte transits the main process, double-JSON-encoded. Plus terminal-specific query
parsing in the shared upgrade handler (
server.tsx:248-252,WSData:51-52) and wiring at:7, 38, 143, 234, 335. Auth at:236-246is correct. Identity ships inside the payload asuserLabel/sessionId(websocket.ts:56, 65) instead of asX-Officer-User. sidecar-registry.ts:395-407plus PTY types threaded through generic plumbing at:12-13, 35, 93, 125, 153, 171, 180, 194. One subtlety to preserve: the 30ssendPtyCommandAsynctimeout atwebsocket.ts:96is load-bearing backpressure forpty:ready.hono.tshas no terminal router at all —terminalappears only at:42. There is nothing to thin down; there is something to create.data-path.tsis bypassed: terminal usesprocess.env.HOME!(websocket.ts:63-64) rather thangetOwnerHomeDir(data-path.ts:34), unlike the eight other host-executing surfaces. Same result on this machine (HOME_DIRis set and equalsHOME), divergent anywhere it isn't.
Shortest path (inferred): git mv the sidecar into src/servers/sidecar/pty/ with its templates,
switch it to connect.ts, move the PtyInitConfig construction and cwd resolution into it, and
replace websocket.ts with the devServerWebsocket relay shape. The detach-on-disconnect policy moves
with it.
vnc — not compliant. ~239 platform lines, 170 of them in api/desktop/
Structurally closer to compliant than most, and blocked on one concrete missing piece rather than a pile of leaked logic.
api/desktop/websocket.ts:1-122— session lifecycle plus a raw RFB TCP tunnel.startVnc(:21-25),Bun.connectto the VNC port (:35-37), both pump directions (:41,:103), apendingMessagesbuffer for pre-connect frames (:9, 73-76, 97-100), and close code4004(:30, 67, 87). The blocker: the vnc sidecar exposes no HTTP or WS listener at all — there is noBun.serveanywhere insidecar/vnc/, and novnc:serverport event, unlikeslskd:server(protocol.ts:64),vault:server(:62) andmusic:server(:60). So the platform cannot forward to it even in principle; the TCP tunnel exists because there is nothing else to talk to. Adding a listener is the prerequisite for everything else here.sidecar-registry.ts:409-439— five VNC verbs.stopVnc(:427) has no callers.sidecar/protocol.ts:23-28, 44-49, 115-129— four commands and five events, withVncStartParams(:117-121) andVncSessionInfo(:123-129) exposingdisplayandpidto the platform — internal process detail crossing the boundary.api/desktop/rest.ts:11-29— a/vnc-passwordfallback ladder in the platform, when the sidecar is already idempotent about this (sidecar/vnc/index.ts:28-36→vnc-manager.ts:57).:31-37also invents its own response envelope.api/desktop/vnc-config.ts:1-10— the main process reads~/.vnc/passwordin plaintext (:4, 7, 9). The same layout is duplicated atvnc-manager.ts:58-60, so the sidecar already knows it. This is the one security-relevant item on this path: a credential read that has no reason to happen inofficer.server.tsx:309-312—/novnc/*maps the request pathname ontopublic/<pathname>with no auth and no traversal guard. Flagging as observed, not diagnosed — whether it is exploitable depends on howpathnameis normalised before it gets there, which I did not trace. Worth a deliberate look given it is the one unauthenticated static path in the file.- Wiring at
server.tsx:13, 45, 149, 234, 339is fine, andhono.ts:37, 122is already reference-shaped (two lines).
Adjacent, and its own domain rather than a vnc violation: the browser relay —
server.tsx:369, 371 plus api/browser/relay.ts (677 lines), api/browser/router.ts (198, including
Bun.spawn(['zip', …]) at :26-30), cdp.ts (99) and relay-auth.ts (42); and
api/scrape/scrape.ts:9-19, 49+ launches chromium in-process. Noted for a future pass; not counted
above.
Cross-cutting: the shared infrastructure
The per-sidecar sections above are symptoms. These are the four things that let the same mistake happen eight times.
1. The proxy-size table
Sorted by how far each is from the reference. This is the whole audit in one view:
| sidecar | platform lines | verdict |
|---|---|---|
| slskd | 70 | ✅ reference |
| music | 88 | ✅ compliant (the cliamp subsystem beside it is not) |
| pty | 169 | ✗ ~all of it is sidecar logic |
| vnc | 170 | ✗ blocked on a missing listener |
| vault | 499 | ◐ partial, security-relevant |
| opencode | 478 | ✗ (~330 lines deletable as dead) |
| claude | ~2,300 | ✗ |
| ~2,875 | ✗ no proxy exists at all |
hono.ts mounts 36 routers. Three are thin sidecar proxies — :106 (music), :107 (slskd), and
:77 (vault, mounted outside protectedRouter).
For contrast, sidecar-side LOC: music 1,630 · claude 1,523 · slskd 653 · opencode 427 · vnc 326 · email 314 · vault 295. Note the inversion on email: 314 sidecar lines to 2,875 platform lines.
And api/* by size, which shows where the mass actually sits: tasks 2,399 · chat 2,227 · email 1,782 ·
file-browser 1,465 · server-settings 1,452 · browser 1,105 · auth 607 · system-monitor 500 · vault 499
· … · desktop 170 · terminal 169 · music 88 · slskd 70.
2. The protocol is not a transport
sidecar/protocol.ts is a closed union of ~34 message types: 7 transport, 25+ domain. Every new
sidecar capability requires editing a shared platform file — which is why domain knowledge keeps
landing there (CLI flags, display/pid, proxySecret, spawn params).
Two specific consequences:
protocol.ts:1importsChatEventandMessageCostfrom../api/chat/types. The transport layer depends on one feature's domain model.- Nine
queue:*message types are undeclared, passed through by amsg.type.startsWith('queue:')string test (server.tsx:80,sidecar/email/index.ts:31, 38). So the "closed" union is already being bypassed where it was inconvenient — evidence that the closed shape is the wrong shape.
By contrast registration-protocol.ts (16 lines: name + capabilities: string[]) is genuinely
generic. The registration handshake got this right; the command channel did not.
3. Ten WebSocket providers, and only three are tunnels
platform/CLAUDE.md says eight; it is ten (add vault and sidecar). Of those, only vault,
desktop and dev-server are byte tunnels. task-runner, pipeline, cliamp and cliamp-audio,
plus the browser relay, spawn and manage child processes inside the auth proxy — ~2,200 lines. So the
"thin proxy" rule is violated by roughly as much code in the WS layer as in all the routers combined.
api/desktop/ is the right model to copy (an opaque byte pump). dev-server is the right model for the
relay itself — including the detail that it defers JWT verification into open()
(server.tsx:168-186) because Bun's upgrade must be synchronous. Anyone building a claude or pty relay
will hit that same constraint.
Port discovery across all of them depends on ES-module side-effect imports (hono.ts:27, 28, 29,
server.tsx:19) — a sidecar's port is remembered because a module happened to be imported. Fragile,
and invisible at the call site.
4. Three copies of the queue engine, two of them dead
queue/init.ts— 290 lines, livequeue/engine.ts+queue/index.ts— 283 lines, deadsidecar/queue-runner.ts— 285 lines, dead
queue/init.ts:8 comments that it was "moved from sidecar/queue-runner.ts" — i.e. work migrated from
a sidecar back into the main process, the opposite direction to the rule. The comments at
bootstrap.ts:4, 15 are false. And both live job kinds are email-domain and run in officer, which
is item 2 of the email section arriving from a different direction.
5. Registry bugs that will bite during any migration
unregisterSidecar(sidecar-registry.ts:80-85) rejects the entire global pending-command map when any single sidecar disconnects. So restartingofficer-musicfails in-flight claude, pty and vault commands. This will look like random unrelated breakage the moment sidecars restart independently — which is the entire goal.:88-90treatscapabilities.includes('proxy')as "is this claude" — true only by accident of the naming confusion documented inCLAUDE_SIDECAR_ISOLATION.md.
6. What the database says (the clearest signal in the audit)
Table ownership tracks compliance exactly:
queries/music.tsandqueries/soulseek.ts— read only by their sidecars. Exemplary, and it is no coincidence these are the two compliant proxies.queries/vault.ts— platform-only, including the three auth handlers (signout.ts,revoke-handler.ts,panic-handler.ts).queries/chat-events.ts— platform-only, which is exactly what makes anofficerrestart lossy.queries/email-accounts.ts— split, withapi/chat/websocket.ts:11, 61-62reaching across domains into it.
A useful rule falls out of this: if a table is read by exactly one sidecar and nothing else, that sidecar is probably compliant. If the platform reads it, the platform probably owns logic it shouldn't. Cheaper to check than reading 3,000 lines.
End of Pass 1 (backend / API). Pass 2 covers the frontend — how each sidecar's UI talks to it, mirroring the slskd findings at the top of this document.
Pass 2 — frontend code
Same rule, applied one layer out. The question here is not "what logic runs in officer" but "does
the browser know things only the sidecar should know?" — upstream URL shapes, wire formats, session-id
conventions, retry and reconnect policy, capability catalogues.
The slskd case at the top of this document is the template: 37 raw /slskd/api/v0/… calls against 10
/slskd/_officer/… calls, meaning the browser is a second client of the upstream API rather than a
client of Officer. Each section below asks the same question of one sidecar.
Ordered most-coupled first, not alphabetically — so the ordering differs from Pass 1 deliberately.
claude / chat — the frontend is better than the backend
An unexpected result, and the most important single finding in Pass 2: the chat frontend already implements the reconnect-and-replay contract that the backend cannot honour. Where the slskd browser code is a second client of an upstream API, the chat browser code is a well-behaved client of Officer. The leakage here is vocabulary, not architecture.
Main files: officerdev/src/hooks/useChat.ts (384), hooks/src/useChatWebSocket.ts (84),
apps/Chat/types.ts (75), state/src/useClaudeSessions.ts (97), state/src/useModels.ts (97),
Chat/components/ModelSelector.tsx (170), ChatHistory/ChatDetailPanel.tsx (156),
ChatHistory/SessionList.tsx (210), Chat/EmbeddableChat/useEmbeddableChat.ts (274),
Chat/components/MessageBubble.tsx (204).
Reconnect and replay — verified working, and it changes the isolation plan
hooks/src/useChatWebSocket.ts:46-58 auto-reconnects with linear-capped backoff
(Math.min(5000, 300 * retry)), resetting the counter on a successful open (:33). And the browser
already tracks a sequence cursor:
// useChat.ts:145-146
const seq = (data as { seq?: number }).seq;
if (typeof seq === 'number' && seq > cursorRef.current) cursorRef.current = seq;
// useChat.ts:282-285 — on every (re)connect once OPEN
const sid = sessionIdRef.current;
if (sid) sendRef.current({ type: 'resume-cursor', sessionId: sid, cursor: cursorRef.current });
The comment at useChatWebSocket.ts:6-8 states the intent exactly: re-bind to the session and replay
events missed while briefly disconnected. On top of that, sync:messages rebuilds full transcript
state including re-arming a mid-stream buffer (useChat.ts:209-237, :233-236), and there is real
disconnected UI — a red "Disconnected" indicator (ChatDetailPanel.tsx:38-52), send disabled
(InputArea.tsx:84), model switching locked (ModelSelector.tsx:67).
So seq + resume-cursor already exist end to end. Pass 1 found the matching backend half at
chat/websocket.ts:612-629 (getChatEventsSince). The protocol is not missing; the writer is simply
on the wrong side of the socket. That makes the durability stage of CLAUDE_SIDECAR_ISOLATION.md
substantially smaller than I estimated — a relocation, not a new mechanism.
One real gap: a silent replay gap is undetectable by the browser. resume-cursor is only sent when
sessionIdRef.current is already set, no sync:messages is forced on reconnect, and nothing validates
that the server actually had events from cursor onward. If the server's retained range were shorter
than the gap, the UI would show a seamless conversation with a hole in it. Worth a sequence-continuity
check when the writer moves.
What the browser knows that it shouldn't
- Hardcoded model ids and provider names.
state/src/useSettings.ts:86-87, 99hardcodes'claude-code'three times as a default.state/src/useModels.ts:68, 96branches access policy onm.provider === 'claude-code'— twice.Chat/components/ModelSelector.tsx:7-23carries aPROVIDER_DISPLAYmap of 15 provider ids. - A fully-qualified Claude model id, in an unrelated app.
FileBrowser/FileBrowserApp/components/TaskRunnerModal.tsx:1362—useState(settings.tasks.defaultModel ?? 'claude-haiku-4-5-20251001'). A dated upstream model string, in the task runner. This one silently goes stale. - The CLI invocation string is in the browser.
apps/Terminal/index.tsx:32-33—command="claude --dangerously-skip-permissions",statePrefix="claude-code". The browser decides how the agent binary is invoked, including its permission flag. (The unsandboxed posture is deliberate perplatform/CLAUDE.md; the objection is only to where the decision lives — the browser is the furthest possible place from the sidecar that owns it.) - Capability metadata crosses to the client.
Chat/types.ts:11-19typescontextWindow,maxTokensandreasoning?, andModelSelector.tsx:116branches the UI onreasoning. The browser doesn't compute these, so this is acceptable if they come from the sidecar — but Pass 1 found them hardcoded in the platform atapi/chat/list-models.ts:5-9, so today the numbers originate two layers away from the thing they describe. - Claude CLI session conventions are documented in the browser.
state/src/useClaudeSessions.ts:8-9comments that the id is the transcript filename;SessionList.tsx:10-11explains that clicking a session continues it "via --resume";:15typesharness?: 'claude' | 'opencode'. And the magic string'general_chat_sessions'— Claude's own default directory bucket — appears as a literal inPwdSelector.tsx:7, 22, 62,ChatDetailPanel.tsx:86, 101anduseClaudeSessions.ts:42. GET /chat/modelsreturnshostHome(useModels.ts:38) — a host filesystem path handed to the browser.- Route balance: 7 chat-domain routes against ~6 generic ones.
/chat/models,/chat/pwds,/chat/sessions,/chat/sessions/:id(with?before=&limit=pagination straight against the transcript store),/chat/sessions/:id/title,/api/chat/ws. Against generic/scrape,/upload,/user/settings,/server-settings/chat-providers/*,/tasks/:id. Unlike slskd these are all Officer-owned paths — nothing bypasses the platform — but they are shaped around Claude's session model rather than a neutral conversation resource. - The event state machine is duplicated a third time — in the browser.
Chat/types.ts:43-55declares a 12-memberServerMessageunion anduseChat.ts:148-277switches on all of it, assembling deltas, tracking tool calls bytoolCallId, and handlingtask:started/task:notification. Pass 1 found the same interpretation inchat/websocket.ts:168-312and again inpipeline-executor.ts:151-201. Three implementations. The browser's copy is legitimate — it renders — but it means the wire format has three consumers to keep in step.
Verified clean
- No JSONL parsing, no
leafUuid, no~/.claudepath construction in the browser. Those stop at the backend. The browser knows the concepts by name only. - No pricing table or cost formula client-side.
MessageBubble.tsx:163-170sumsinputTokens + outputTokensand formats atotalUSDit was handed (Chat/types.ts:5-9). Correct placement. - Lifecycle control is two logical verbs, not process control:
stop(turn) anddisconnect(session) —useChat.ts:352-360. No PIDs, no process assumptions. This is exactly the abstraction a relay-based architecture needs, and it already exists.
email — routes are compliant, payloads and realtime are not
The mirror image of chat: every one of the 20 API paths is Officer-shaped — there is no
/imap/uid/… anywhere — but the request bodies carry IMAP configuration, the compose path builds
MIME, and the realtime channel cannot recover from a restart at all.
There is no windowed panel app; the email UI is screen-level under
officer-web/Screens/Dashboard/Email/** plus account setup in Settings/. ~2,451 lines total:
EmailAccounts.tsx (512), Compose.tsx (393), EmailList.tsx (377), SMTPSection.tsx (292),
GoogleOAuthConfig.tsx (288), EmailReader.tsx (224), GoogleAccount.tsx (98, dead — commented
out of IntegrationsSettings/index.tsx:11, 71-79), EmailScreen.tsx (60), types/email.ts (29).
-
The browser owns IMAP connection parameters.
EmailAccounts.tsx:21-37holdsimapHost,imapPort(default'993') andimapSecureas first-class client state;:82hardcodes the Gmail presetimap.gmail.com:993;:98-107and:124-133POSTimapHost/imapPort/imapSecure/authType. Provider detection is a hostname string comparison in the browser (:123):const provider = form.imapHost === 'imap.gmail.com' ? 'gmail' : 'imap';. Provider presets are the sidecar's knowledge by definition. -
MIME construction in the browser.
Compose.tsx:218-236clones the editor DOM, rewrites each inline<img>tocid:inline-N, and ships the parts — the browser authoring thecid:URI scheme of amultipart/relatedmessage. Its own comment admits the contract is positional ("backend matches by order"), which is a fragile coupling in both directions. Inline-vs-attachment is decided byf.type.startsWith('image/')(:176,:195). -
No HTML sanitisation of message bodies.
EmailReader.tsx:19-53writes server-supplied HTML directly into an iframe viadoc.write(...), withsandbox="allow-same-origin"and noallow-scripts(:52). No DOMPurify or equivalent anywhere. So the iframe sandbox is the only thing preventing script execution from hostile mail — it does hold, since scripts aren't allowed, but it is a single un-defended layer, andallow-same-originwithoutallow-scriptsis a combination worth a deliberate second look rather than an accident. -
Two plaintext secrets are fetched into the browser and cached.
SMTPSection.tsx:39—GET /server-settings/smtpreturns the SMTPpassword(and ResendapiKey);:63writes it into state;:208-213binds it to atype="password"input. It lives in the React Query cache under['SMTP_CONFIG']for the life of the tab.GoogleOAuthConfig.tsx:197-210—GET /integrations/google/configreturnsclientSecretin plaintext; held inuseState(:184), shown at:255-262.
Neither is a mail credential the sidecar owns, and both are the owner's own secrets on the owner's own machine — but "GET returns the secret so the form can prefill" is the pattern worth changing, since a write-only field would work identically.
-
The session bearer token is passed in a URL.
EmailList.tsx:110-112buildsnew EventSource('/api/email/events?token=' + …)fromlocalStorage. Unavoidable forEventSource(it can't set headers), but it puts the JWT into browser history and any proxy access log. Worth noting that the chat socket does the same atuseChat.ts:121-123. -
The realtime channel cannot survive a restart — explicit, not inferred.
EmailList.tsx:109-125opens the SSE stream, expects{ type: 'new-mail' }, invalidates three query keys, and closes on unmount. There is noes.onerror, no backoff, no reconnect, and noLast-Event-IDhandling. And the server never sends anid:field — Pass 1'sapi/email/email.ts:101emits onlydata: {"type":"new-mail"}— so even the browser's nativeEventSourceretry cannot request replay. Anynew-mailevent emitted during a restart is lost silently until the next event arrives or the user hits Sync manually (:134-154). Direct contrast with chat, in the same codebase: one channel has cursor-based replay, the other has nothing. That gap is the template for what to fix, and chat is the template to copy. -
Retry and polling policy decided in the browser, duplicated. A fixed 5,000 ms account poll while any account is
syncing/queued—EmailList.tsx:157-167, duplicated verbatim atEmailAccounts.tsx:65-70. Search debounce 300 ms (EmailList.tsx:63-66), contact autocomplete 180 ms (Compose.tsx:28). No backoff anywhere; a failed sync gets onetoast.error. -
A client-side folder heuristic.
EmailList.tsx:95-105: ifinboxreturns zero but anallprobe finds mail, the browser silently switches folder. Business logic about mailbox state, decided in the UI. -
Gmail knowledge, mostly as copy rather than behaviour.
GoogleOAuthConfig.tsx:15-18lists the OAuth scopes (gmail.readonly,calendar.readonly) and:104-111,:116explain Google's "restricted" classification — documentation text, not requests the browser makes.EmailScreen.tsx:13puts Gmail-vs-local-DB routing into an agent system prompt ("Do not use the Gmail integration for questions about existing emails"), which is domain routing logic expressed as English in the frontend.Compose.tsx:241knows Gmail files its own sent copy.
Verified clean
- Threading is server-side. The browser receives
EmailThreadpre-grouped (EmailReader.tsx:130-134,types/email.ts:24-29) and consumes precomputedthreadCount/threadUnread. No participant computation, no subject normalisation, no dedup. (Compose.tsx:385-393doesRe:prefixing for a reply draft — cosmetic.) - Folder names are Officer's, not IMAP's.
EmailList.tsx:35-41is a fixedinbox/all/sent/spam/trashlist passed asfolder=; no special-use flag mapping. - No charset, quoted-printable, base64 or RFC-2047 decoding in the browser — it receives decoded
text/html/snippet. Reading is compliant; only composing leaks. - No Gmail label ids and no Gmail query syntax constructed client-side. The search box passes
q=through untouched (EmailList.tsx:83-85);:309's placeholder only hints at the syntax. - Mail credentials are write-only. The password is POSTed at
EmailAccounts.tsx:132and never read back —GET /email/accountsreturns no credential field. OAuth tokens never reach the browser at all::88-107either redirects the page to/api/integrations/google/authorizeor POSTscredentials: { userIntegrationId: true }, a boolean. This is the right shape, and it is worth noting that the account credential path is stricter than the settings ones in item 4. - No Message-Id handling — and
Compose.tsx:382-384documents the absence, notingm.idis a local hash and that threading currently leans onRe:+ participants.
opencode — the most compliant frontend of the eight
Genuinely surprising given Pass 1 found ≈792 non-compliant backend lines. The string opencode
appears in exactly four frontend files, and only one of those is logic. Everything the backend leaks
— the ses_ prefix, the opencode/<modelID> id shape, metadata.officer, auth.json,
models.json, the version pin — stops at the server. Verified by exhaustive grep: zero frontend hits
for ses_, opencode/, metadata.officer, auth.json, models.json.
The four mentions: state/src/useClaudeSessions.ts:15 (harness?: 'claude' | 'opencode'),
ChatHistory/SessionList.tsx:153-156 (renders an "OpenCode" badge),
Chat/components/ModelSelector.tsx:22 (opencode: 'OpenCode Zen' display label), and
Chat/EmbeddableChat/useEmbeddableChat.ts:108-109 — a comment only, explaining that cwd is sent
every turn because OpenCode rebuilds its working directory each turn. The code isn't gated on harness;
it just always sends cwd.
- Harness selection is not opencode-aware at all — it is
claude-code-aware. The only string test is a Claude one, and everything else falls through to the opencode path:state/src/useModels.ts:68(if (m.provider === 'claude-code') return true;) and:96. Both are access-policy bypasses: Claude models always pass the admin policy filter; every opencode-routed provider is subject to it. Worth knowing that's the actual semantic, since it isn't obvious from the code. - Session UI is shared, not forked.
SessionList.tsx/ChatDetailPanel.tsxserve both harnesses off the same/chat/pwds,/chat/sessions,/chat/sessions/:id,/chat/sessions/:id/titleroutes, and the browser treatsidas an opaque string — no format validation, noses_regex, no branch on id shape.ChatDetailPanel.tsx:96-98resume/pagination is harness-agnostic. - The WebSocket machinery is fully shared with no opencode branch.
useChat.ts:121-123builds one URL unconditionally;hooks/src/useChatWebSocket.tstakes only{url, onMessage, onOpen}; the 12-memberServerMessageunion (Chat/types.ts:34-46) has zero opencode-specific variants. So the reconnect-and-replay work described in the chat section above benefits both harnesses for free. - API keys are never echoed to the browser — verified against the server.
AIHarnessesSection.tsx:57GETs/server-settings/chat-providers/api-keys, butapi/server-settings/chat-providers.ts:384-391masks server-side first (value.slice(0,3) + '...' + value.slice(-3)), and the browser uses the result only as a placeholder (AIHarnessesSection.tsx:467). A freshly typed key lives transiently inkeyInputsstate (:74) and is deleted after the PUT (:121-125). Never inlocalStorage,sessionStorage, or the query cache. Local-provider config returns the auth type only, never key material. This is the pattern the email settings surface (Pass 2, email item 4) should copy. - No hardcoded model catalogue.
state/src/useModels.ts:30-51fetches everything from/chat/models. The only hardcoded data is display-name maps —ModelSelector.tsx:7-23(14 pairs) and a near-duplicate atAISettings.tsx:19-34(13 pairs, falling back to server-suppliedproviderNamesviauseModels.ts:18-20). Two overlapping copies of a label map is a small duplication, not a boundary violation. - Naming debt, not coupling. The shared hook is called
useClaudeSessions, and:9commentsid: string; // Claude session uuid (= transcript filename)while that field also carries opencode'sses_…ids;:15treats absent harness as claude;:58calls the store "Claude's own transcript store". Accurate when written, misleading now. - No dead opencode frontend code. Verified importers for all four files —
ModelSelector.tsx(viaInputArea.tsx:92,ChatLauncher.tsx:107,TaskRunnerModal.tsx:202),SessionList.tsx(routed atApp.tsx:38-40),useClaudeSessions.ts,useEmbeddableChat.ts. All reachable.
One thing the frontend displays that isn't real, and the cause is in the backend.
api/chat/list-models.ts:24 stubs every opencode-routed model with constant metadata —
contextWindow: 200000, maxTokens: 8192, reasoning: false, images: true, with the comment "metadata is
left at neutral defaults for now". The browser faithfully renders these (ModelSelector.tsx:116
branches the thinking toggle on reasoning). So the capability numbers shown to the user for opencode
models are placeholders, and reasoning: false will suppress the thinking toggle for models that do
support it. A backend defect, surfaced by a compliant frontend.
terminal / pty — the browser reconnects, and then loses the session anyway
The mirror of the backend result. Pass 1 called pty the least compliant backend surface; the frontend is mostly well-behaved, has real reconnect logic, and yet contains one bug that defeats the entire detach-not-kill design.
All terminal UI funnels through one component. Terminal/Terminal.tsx (329) holds every piece of logic;
CommandTerminalWrapper.tsx (59), TerminalWrapper.tsx (57), HostTerminalWrapper.tsx (50),
Terminal/index.tsx (76), Headers.tsx (79). Reused by FileBrowser/CliampPanel.tsx (36) and
Settings/SystemSettings.tsx (151). Verified: no code editor, dev-server or task-runner panel embeds
a PTY — the apparent matches were lucide icons and an unrelated isTerminal(status).
The session-orphaning bug
The backend deliberately detaches rather than kills, so a shell outlives a dropped socket. The browser throws that away on any ordinary unmount:
// CommandTerminalWrapper.tsx:32-39 — and identically TerminalWrapper.tsx:35-42,
// HostTerminalWrapper.tsx:28-35
useEffect(() => {
return () => {
setTerminalsRef.current((prev) => {
const { [panelId]: _, ...rest } = prev;
return rest;
});
};
}, [panelId]);
That cleanup deletes the panelId → sessionId mapping when the panel closes, the workspace layout
changes, or a client-side route change unmounts the wrapper. The next mount calls
crypto.randomUUID() again (CommandTerminalWrapper.tsx:28, TerminalWrapper.tsx:30,
HostTerminalWrapper.tsx:24) and connects with a new session id — leaving the previous detached
shell running server-side with no reference to it, ever. Combined with Pass 1's finding that
pty:close has no sender anywhere, there is no kill path either: those shells accumulate.
So: reconnect works for network drops and tab backgrounding; closing a panel silently leaks a shell.
Reconnect — implemented, and independent of the chat implementation
// Terminal.tsx:141-145
const MAX_RECONNECT_ATTEMPTS = 5;
const RECONNECT_DELAYS = [1000, 2000, 3000, 5000, 5000];
handleClose (:235-254) reschedules connect unless the process exited or the effect was disposed,
gives up after 5 attempts, then reports disconnected (:249-253); the counter resets on a successful
open (:173). There is also a visibility-triggered reconnect (:272-280) that resets the counter
and reconnects immediately when the tab is refocused. Because connect() rebuilds the URL from the same
sessionId prop (:162), every retry re-presents the same id — re-attachment is correct by
construction.
Note this is a third, unrelated backoff implementation: a 5-entry table here, Math.min(5000, 300 * retry) in useChatWebSocket.ts:46-58, and a byte-identical copy of the sidecar table duplicated
between pty-sidecar.mjs:37 and connect.ts:22 (Pass 1). Four backoff policies, no shared helper.
Scrollback survives, but the browser doesn't know it. There is no replay message type at all. The
sidecar keeps a capped 50KB buffer (pty-sidecar.mjs:36, BUFFER_MAX) and re-emits it on re-init
(:99-101); the bridge forwards it as an ordinary output frame
(api/terminal/websocket.ts:71-79), and Terminal.tsx:181 term.write()s it indistinguishably from
live output. No dedup, no historical marker. It works, passively. (INFERRED: survival across a hard
page reload depends on React cleanup not running during navigation teardown — standard behaviour, but
not verified against pagehide here.)
Session ids are chosen by the browser and persisted server-side through useDashboardState →
GET/PATCH /dashboards (React Query key ['DASHBOARD_STATE'], staleTime: Infinity), so they survive
a reload. Key derivation is inconsistent across the three wrappers: CommandTerminalWrapper.tsx:19 and
HostTerminalWrapper.tsx:12 template dashboardId directly, while TerminalWrapper.tsx:12-18
regex-matches ws-layout-(.+) / proj-layout-(.+) to build its key. SystemSettings.tsx:92 opts out
with an ephemeral `run-cmd-${Date.now()}` in local state — deliberate for a one-shot panel.
Shell knowledge in the browser
- The browser composes shell commands by string concatenation, unescaped.
That assumes a POSIX shell (
// Terminal.tsx:187-190 const wrapped = onCommandDoneRef.current ? `${commandRef.current}; echo "${EXIT_MARKER}$?__"` : commandRef.current; ws.send(JSON.stringify({ type: 'input', data: wrapped + '\r' }));;,$?,echo) and does not escapecommand. Same pattern atCommandTerminalWrapper.tsx:46-47, which also does tilde/home expansion in the browser and buildscd <path> && <command>:The inputs are the app's own hardcoded commands and a cwd from the workspace, not free user text — so this is a correctness and placement problem rather than an injection hole today. It becomes one the moment a command string is user-supplied.const cwdPath = cwd && cwd !== '~' ? (cwd.startsWith('~') ? cwd : `~/${cwd.replace(/^\//, '')}`) : null; const fullCommand = cwdPath ? `cd ${cwdPath} && ${command}` : command; - A sentinel protocol invented in the browser.
Terminal.tsx:151(EXIT_MARKER = '__OFFICER_EXIT_'), a hand-rolled ANSI stripper at:152-153(CSI plus BEL-terminated OSC only — misses ST-terminated OSC), and echo detection byl.includes(commandRef.current!.slice(0, 20))at:212. Command completion is inferred by scraping terminal output for a marker the browser injected. - Hardcoded CLI invocations in the bundle:
Terminal/index.tsx:13(tmux),:17(nvim), and:32—command="claude --dangerously-skip-permissions". Each is then wrapped with thecdprefix and the exit-marker suffix above and typed into the pty as keystrokes. - No hardcoded shell path in the browser —
/bin/zshlives atapi/terminal/websocket.ts:62(Pass 1, item 1a). Default cwd'~'comes fromWorkspaceContext.ts:34.
Two cross-checks that confirm Pass 1 findings
- The backend's
cwdhandler is unreachable. Pass 1 flaggedapi/terminal/websocket.ts:129-138for synthesizing`cd ${JSON.stringify(msg.path)}\r`. Repo-wide grep finds zero frontend senders of{type:'cwd'}— the browser does its owncdcomposition instead (item 1 above). So that branch is dead, and the capability it implements is duplicated in the client. detachedis dead in the other direction.Terminal.tsx:225-226handles a'detached'message and writes[Session taken over], but no backend code ever emits it — the onlydetachedinsrc/serversis an unrelated field atactivity/router.ts:98. The frontend author modelled a takeover concept the server never implemented.
Also dead: officerdev/terminal-host (Terminal/index.tsx:47-54, HostTerminalWrapper.tsx) is
registered availableOnPanel: false and referenced by no route, layout or panel map.
Ongoing resize is not implemented
fitAddon.fit() is called exactly once per connect() (Terminal.tsx:158). There is no
ResizeObserver and no window resize listener in the file. The browser is the authority on
dimensions — it creates the terminal at a fixed 80×24 (:120-121), fits after layout settles, then
sends cols/rows both as query params (:59-60) and as an initial resize message (:175), and the
server never overrides them. Consequence: resizing the window or dragging a panel splitter does not
re-fit or notify the server until the next reconnect. Not an architecture violation — a real bug, and
the one item in this section a user would notice daily.
Verified clean
- No PIDs, no tmux internals, no process-lifetime or kill semantics in the browser.
exitcarries no code and the backend sends none (api/terminal/websocket.ts:84), so there is nothing to leak. - The browser never generates ANSI for rendering —
term.write(msg.data)(:182) hands raw bytes to xterm.js. The only parsing is the sentinel stripper above. CliampPanel.tsx:25, 35reuses the same component against/api/cliamp/wswith a differentwsPath— evidence the client really is a generic PTY-over-WS view, agnostic to whether a shell or mpv is on the far end. The env the cliamp sidecar sets (api/cliamp/websocket.ts:112) is never surfaced to the browser.
music — the compliant frontend reference
12 routes, all Officer-owned /music/…, zero upstream-shaped calls. Put beside slskd's 37-vs-10,
this is what the rule looks like when it's followed on both sides. Unsurprising given Pass 1 found the
music backend proxy compliant at 88 lines — and the pairing is the point, see the cross-cutting note
below.
Files: MusicPlayer/MusicPlayerHost.tsx (370), apps/Music/MusicDetail.tsx (363),
MusicPlayer/gapless-engine.ts (325), apps/Music/MusicBrowser.tsx (233),
widgets/MusicPlayer/index.tsx (200), apps/Music/FavoritesView.tsx (188),
apps/Music/shared.ts (123), useMusicFavorites.ts (52), useMusicPlayer.ts (47),
MusicScreen.tsx (44), MusicHeart.tsx (43).
Routes: /music/manifest, /music/meta, /music/cover, /music/discography, /music/reindex,
/music/favorites (GET/POST/DELETE), /music/now-playing (GET/PUT/DELETE), /music/stream. Plus the
generic /file-browser/ls for directory walking — deliberate per MUSIC_API.md:11-13, and the right
call: no music-specific listing endpoint was invented.
Documented but never called by the frontend: /music/reindex/status, /music/reindex/stream,
/music/poster, /music/lyrics.
What little leaks:
MUSIC_ROOT = 'Music'hardcoded twice —shared.ts:4andwidgets/MusicPlayer/index.tsx:12. A home-relative prefix convention, not a filesystem path (no absolute path, no env var in the browser). Mild.- The client re-sorts what the sidecar returns, and says why:
shared.ts:63comments that "meta.json is in ffprobe/readdir order (arbitrary)", sosortTracks(:65-75) re-orders by track tag. The browser is compensating for the sidecar's output format. The correct fix is in the indexer, not the client. AUDIO_EXTduplicated verbatim —shared.ts:96andwidgets/MusicPlayer/index.tsx:19— a 9-extension allowlist used to filter rawfile-browser/lsresults. Plus an album-name convention regex (ALBUM_NAME_RE,shared.ts:12) for"[year] Album Name".- A playback design decision worth surfacing, though not a compliance issue.
gapless-engine.ts:86-89does a plainfetch(url)→arrayBuffer()→decodeAudioData()with noRangeheader, downloading and decoding each whole file to PCM. Its own comment (:9-11) puts a ~10-minute track at ≈200 MB in memory, bounded only byCACHE_MAX = 3(:26). So the byte-range support the API offers is never exercised by this path, andX-Audio-Durationis ignored in favour ofAudioBuffer.duration(:199,:231). A deliberate trade for sample-accurate gapless transitions — just an expensive one.
Verified clean: no codec, container, MIME, remux or MediaSource logic anywhere (grepped —
zero hits); no cover-art filename convention (always the opaque /music/cover?path=, with meta.cover
used only as a boolean); no tag-format knowledge; no reindex internals (POST /music/reindex is a
black-box trigger and the status/stream routes are never read).
cliamp — the browser hardcodes the sidecar's capture format
Two sockets, and they behave very differently. The control channel is exemplary; the audio channel is the most tightly coupled thing in Pass 2.
FileBrowser/AudioStreamPlayer.tsx (164), FileBrowser/CliampPanel.tsx (36),
FileBrowser/pcm-worklet-processor.js (41, dead — zero importers; AudioStreamPlayer.tsx:19-58
inlines its own copy of the same worklet as a string and loads it via Blob/createObjectURL at :60).
The control channel is genuinely opaque
useFileBrowserApp.ts:443-446 sets ?play=<path>; CliampPanel.tsx:25 builds
/api/cliamp/ws?files=<path>; :35 renders the generic TerminalView. That's the entire
integration. Verified: the browser has no idea mpv is on the other end — no keybinding table, no
command syntax, no output parsing. Keystrokes are forwarded byte-for-byte
(Terminal.tsx:265-269) and output goes straight to xterm's generic ANSI parser (:182).
The consequence is a UI observation rather than a violation: there are no transport controls. No
play/pause, seek or volume — CliampPanelHeader (CliampPanel.tsx:7-19) shows only the filename split
off the URL (:10). The user reads mpv's own status line as rendered raw. That is the honest cost of a
fully opaque tunnel, and worth deciding deliberately rather than by default.
The audio channel hardcodes the wire format on both sides independently
The sidecar spawns parec --format=s16le --rate=44100 --channels=2 (api/cliamp/audio-ws.ts:29) and
pipes raw bytes with no framing and no header (:57). The browser hardcodes the matching
assumptions, with no negotiation or handshake of any kind:
// AudioStreamPlayer.tsx:9-10
const SAMPLE_RATE = 44100;
const CHANNELS = 2;
s16leis implied bynew Int16Array(ev.data)(:108) andint16[i] / 32768(:111).new AudioContext({ sampleRate: SAMPLE_RATE })(:76) — the browser forces its output rate to match rather than resampling.- Ring buffer capped at
SAMPLE_RATE * CHANNELS * 2samples (:30) ≈ 4 seconds. - Graph: WS binary → Int16→Float32 on the main thread →
postMessage→AudioWorkletNode(pcm-processor, deinterleaving at:42-48) →GainNode(mute) → destination.
Change the parec flags in the sidecar and the browser plays noise. Two files, no shared constant, no
version marker. This is the clearest single instance in Pass 2 of a sidecar's internal format being
duplicated into the client — and cliamp is precisely the subsystem Pass 1 found has no sidecar at
all.
The audio socket has no reconnect. AudioStreamPlayer.tsx:121-123 handles close by flipping a UI
flag and nothing else; the effect only reconnects if the wsUrl prop identity changes (:143) or the
component remounts. A dropped capture socket stays dead until the panel is closed and reopened. The
control socket, by contrast, inherits TerminalView's full backoff — so in the same panel, the terminal
comes back and the audio doesn't.
Also: the music and cliamp paths share no code despite both ending in Web Audio.
vault — there is no vault frontend
Worth stating plainly rather than padding: zero lines of client code. Six grep passes across
src/workspaces/officerdev/src/apps/**, src/apps/officer-web/** and the shared workspaces for
vault|bitwarden|cipher|kdf|PBKDF2|Argon2|crypto.subtle|masterPassword|unlock|/api/vault|TOTP produced
only false positives: helpers/slug.ts:25 (the word "vault" in a slug word list), a base64 audio blob,
and components/ui/input-otp.tsx (the generic shadcn primitive, matched on the substring in
InputOTP). AppRegistry.tsx:22 lists every windowed app — FileBrowser, Terminal, CodeEditor, Chat,
FileViewer, Dashboards, Projects, ChatHistory, Preview, Widgets, Desktop, Music, Soulseek,
SystemMonitor — and there is no Vault entry. Nothing in Settings/**, nothing in the sidebar, nothing
in TODO.md.
So: no client-side crypto, no key custody, no decrypted-secret handling, no lock state machine, no
/api/vault calls, no WebSocket client. Nothing to assess.
But this reframes a Pass 1 finding, and it matters. Pass 1 credited the vault with getting one
thing right: "user-key derivation is genuinely client-side (the platform only relays Kdf* params at
router.ts:96-101)". Since Officer has no vault client, the client doing that derivation is a
third-party Bitwarden app. Which means the platform-side login broker, token store, WebSocket token
injection and at-rest crypto documented in Pass 1 exist to serve external clients, not Officer's own
UI. That is a materially different security story than "our own frontend needs this", and it should be
settled before any of that code is moved: who is actually meant to talk to the vault?
Cross-cutting findings from Pass 2
1. The inverse correlation — and the mechanism behind it
The single most useful thing in this pass. Ranked by frontend compliance:
| sidecar | backend verdict (Pass 1) | frontend verdict (Pass 2) |
|---|---|---|
| slskd | ✅ compliant, 70 lines | ✗ worst — 37 raw upstream calls vs 10 Officer routes |
| music | ✅ compliant, 88 lines | ✅ 12 routes, all Officer-owned |
| opencode | ✗ ≈792 lines | ✅ best — 4 mentions, 1 of them logic |
| claude | ✗ ≈2,300 lines | ✅ mostly clean; vocabulary leaks only |
| ✗ ≈2,875 lines, no proxy | ◐ routes clean, payloads and realtime not | |
| pty | ✗ ~all of 169 lines | ◐ clean boundary, but shell composition + a session leak |
| vnc | ✗ blocked on a missing listener | ✅ 149 lines, one route, no internals |
| vault | ◐ partial | — no frontend exists |
A thin proxy alone does not produce a clean frontend — it relocates the problem. When the platform
translates (claude, opencode, email), the browser receives Officer-shaped data and stays clean at the
cost of thousands of platform lines. When the platform is a dumb pass-through (slskd), the browser has
to speak the upstream API itself. Music is the only case that escapes the trade, and it escapes it
because the sidecar exposes Officer-shaped routes — the /_officer/* namespace — rather than only
proxying the upstream one.
So the rule as stated ("main server is a thin proxy") is necessary but not sufficient. The complete
version is: the sidecar owns the contract the browser consumes. Thinning a router without adding
/_officer/* routes to the sidecar just moves domain logic from the platform into the browser, which is
strictly worse — it is further from the data and unversioned.
2. Resilience is inconsistent, and that is the frontend's real problem
Pass 1 found an architecture problem. Pass 2 mostly finds a resilience problem — and it is per-socket rather than systemic:
| channel | reconnect | replay |
|---|---|---|
| chat WS | ✅ min(5000, 300 × retry) |
✅ seq + resume-cursor (best in repo) |
| terminal / cliamp control WS | ✅ 5-entry table + visibility trigger | ◐ passive 50 KB sidecar buffer; browser unaware |
| cliamp audio WS | ✗ none | — n/a (live capture) |
| desktop / VNC WS | ✗ none at all | — n/a |
| email SSE | ✗ none, and no id: field server-side |
✗ so native EventSource retry can't replay either |
Two channels have thought-through recovery; three have none. The chat implementation is the template and it already works — see the isolation document, where this finding shortened the plan.
Four independent backoff implementations, no shared helper: useChatWebSocket.ts:46-58,
Terminal.tsx:141-145, and the byte-identical pair pty-sidecar.mjs:37 / connect.ts:22 (Pass 1).
3. The bearer token travels in URLs on every socket
useChat.ts:121-123 (chat WS), Terminal.tsx:53-55 (terminal WS), DesktopView.tsx:13-17 (VNC WS),
EmailList.tsx:110-112 (email SSE) all read localStorage.getItem('BEARER_TOKEN') and append
?token=…. Unavoidable for EventSource and awkward for WebSocket (neither can set headers), and
this is the owner's own machine behind their own HTTPS proxy — but it does put a 30-day JWT into browser
history and any proxy access log, on four separate paths. If it's ever worth fixing, a short-lived
single-use ticket exchanged for the socket is the usual shape.
Related, from Pass 1: server.tsx:308-311 serves /novnc/* with no auth, which Pass 2 confirms is
how the vendored noVNC client (public/novnc/rfb.js, 3,415 lines + ~40 modules) is loaded, via a
memoized dynamic import() (DesktopView.tsx:28-37).
4. Credential handling in the browser — one clear right answer, two wrong ones
- ✅ VNC password: fetched once into a local closure variable (
DesktopView.tsx:55), passed to RFB (:83-85,:104-106), never in state, storage, the query cache, or a URL. - ✅ Provider API keys: masked server-side before the GET
(
api/server-settings/chat-providers.ts:384-391), used only as a placeholder (AIHarnessesSection.tsx:467), and the typed value is deleted from state after the PUT (:121-125). - ✅ Mail account passwords / OAuth: write-only; the account list returns no credential, and OAuth
tokens never reach the browser (
EmailAccounts.tsx:88-107). - ✗ SMTP password / Resend API key:
GET /server-settings/smtpreturns them in plaintext and they live in the React Query cache under['SMTP_CONFIG']for the life of the tab (SMTPSection.tsx:39, 63, 208-213). - ✗ Google OAuth client secret: returned in plaintext to prefill a form
(
GoogleOAuthConfig.tsx:197-210, 184, 255-262).
The masking pattern in the chat-providers route is the fix for both failures, and it already exists in this codebase.
5. Dead frontend code, verified by grepping for importers
FileBrowser/pcm-worklet-processor.js(41) — zero importers; the worklet is inlined instead.Settings/IntegrationsSettings/GoogleAccount.tsx(98) — commented out ofIntegrationsSettings/index.tsx:11, 71-79.officerdev/terminal-host(Terminal/index.tsx:47-54+HostTerminalWrapper.tsx) — registeredavailableOnPanel: false, referenced by no route, layout or panel map.Terminal.tsx:225-226— handles a'detached'message no backend ever sends.GET /desktop/vnc-status(api/desktop/rest.ts:31-38) — defined, never called.api/terminal/websocket.ts:129-138— thecwdhandler; no frontend sender exists (the browser composes its owncd), confirming Pass 1's flag from the other direction.
6. Duplicated constants across the frontend
PROVIDER_DISPLAY twice (ModelSelector.tsx:7-23, 14 entries; AISettings.tsx:19-34, 13 entries);
AUDIO_EXT twice (shared.ts:96, widgets/MusicPlayer/index.tsx:19); MUSIC_ROOT twice
(shared.ts:4, widgets/MusicPlayer/index.tsx:12); the PCM worklet twice (one copy dead). Small, but
each is a place where two files must be changed together and nothing enforces it.
7. Three bugs a user would notice, none of them architectural
Recorded because they surfaced during the audit, not because they're in scope:
- Closing a terminal panel leaks a shell.
CommandTerminalWrapper.tsx:32-39(and the two sibling wrappers) delete thepanelId → sessionIdmapping on unmount, so the next mount generates a new uuid and the detached shell becomes unreachable — with no kill path, sincepty:closehas no sender. - Terminals don't re-fit after a resize.
fitAddon.fit()runs once perconnect()(Terminal.tsx:158); there is noResizeObserveror window listener, so dragging a splitter leaves the pty on stale dimensions until the next reconnect. - opencode model capabilities shown to the user are placeholder constants.
api/chat/list-models.ts:24stubs every opencode model atcontextWindow: 200000, maxTokens: 8192, reasoning: false, andModelSelector.tsx:116hides the thinking toggle based on thatfalse.
End of Pass 2. Both passes are complete. Nothing in either document has been changed in code.