> **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: 1. The platform holds **no credentials** for the upstream service, and no knowledge of its API. 2. 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. 3. **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_KEY` are read in **exactly one file**: `src/servers/sidecar/slskd/upstream.ts`. The platform never sees either. - `hono.ts` contains 3 slskd lines: two imports and `protectedRouter.route('/slskd', slskdRouter)`. - `protocol.ts` contains 1: the `slskd:server` port message. - No platform-server file imports any `soulseek*` query from `officerdb`. 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//status` and `/users//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/` 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 `DELETE`s). 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** | 1. **cliamp player process management** — `api/cliamp/websocket.ts:1-201`. Locates the `cliamp` binary by probing `Bun.which` plus 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 spawns `script -qfc ' ' /dev/null` to fake a PTY (`:106-113`) with `PULSE_SINK: 'virtual_out'` and `ALSA_CONFIG_PATH` injected. 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-level `Map` (`:22`). *Belongs in* `sidecar/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-228` for dev-server, `server.tsx:323-326` for vault). 2. **PulseAudio host-daemon bootstrap** — `server.tsx:391-436`. A startup IIFE that locates `pulseaudio`/`pactl`, runs `pulseaudio --start -D` if the daemon is down (`:401-411`), then greps `pactl list short sinks` and loads `module-null-sink sink_name=virtual_out` if 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, `pactl` works identically. Must move together with (1) and (3), since the sink must exist before they start. 3. **Host audio capture → browser PCM** — `api/cliamp/audio-ws.ts:1-91`. Spawns `parec --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. 4. **ALSA config shipped inside the API tree** — `api/cliamp/asoundrc:1-9`, passed via `ALSA_CONFIG_PATH` (`websocket.ts:8`, `:112`). Upstream config in the thin-proxy process. Moves for free — it's resolved by `__dirname`. 5. **Music-domain param in the WS envelope** — `server.tsx:253`, `:265`, `WSData.files` at `:53`. `upgradeWs` plumbs a `files` query param that exists only for this feature. The JWT verify + blacklist check in `upgradeWs` (`:231-272`) is legitimate platform work; the feature-specific parameter is not. 6. **Route names leaking into the proxy** — `api/music/router.ts:29-36`. The catch-all special-cases `/reindex` and `/reindex/stream` by name to call `server.timeout(req, 1800)`. Minor, but the slskd reference router is uniform for every path. Simple fix: extend the timeout for the whole prefix. 7. **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, with `NON_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__ORIGIN` in the environment is an app origin, and its rule is derived from its own name** — `/api/auth` (it has to sign in) plus `/api/`. So `OFFICER_MUSIC_ORIGIN` (renamed from `MUSIC_APP_ORIGIN`) gets `/api/music` without 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) and `APP_SCOPE_OVERRIDES` (`TAIL` → `/api/vpn`, the one app whose name and API surface differ). `NON_OWNER_PATHS` stays 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:123` *is* an `all('/*')` catch-all, but it is not thin — it **replaces** the `Authorization` header with a platform-held upstream credential (`:137-141`) and implements 401-refresh-retry (`:158-165`). - It does **not** inject `X-Officer-User` (contrast `api/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. 1. **Vaultwarden token broker in the main process** — `api/vault/broker.ts:1-79` + `api/vault/router.ts:52-104`. `broker.ts:46-57` builds an OAuth `grant_type=password` form (`username=email`, `password=authHash`) and POSTs it to `/identity/connect/token` (`:15-20`); `:60-66` does the `refresh_token` grant. `router.ts:52-104` is a hand-written `POST /session/login` that parses the body, calls `passwordGrant`, 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-derived `authHash` (`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. 2. **Token custody + refresh in the main process** — `api/vault/token-store.ts:1-34`, called at `router.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. 3. **Notifications WebSocket proxied twice, with token injection** — `api/vault/websocket.ts:1-164`. `injectToken` (`:45-52`) rewrites the SignalR query string: drops `token`, sets `access_token=`. `open` (`:55-118`) verifies the platform JWT, fetches the upstream token, dials `ws://127.0.0.1:` 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 `:73` should not. 4. **The platform is the vault's key escrow** — `api/vault/router.ts:107-120`. `PUT /unlock-key` persists a `wrappedKey` (`:111`); `GET /unlock-key` hands 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. 5. **Vault at-rest crypto executes in the main process** — `officerdb/src/crypto.ts:1-41`, called from `queries/vault.ts:22-23,34-35,67-68,83,88`. Derives an AES-256-GCM key as `SHA-256(VAULT_STORE_KEY)` (`:12-20`) and runs `createCipheriv`/`createDecipheriv` (`:23-39`). Because the vault router imports `officerdb` (`router.ts:10`, `token-store.ts:1`), all of this runs inside `officer`. *Obstacle:* `crypto.ts` lives 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`. 6. **Vault tables are read/written by the platform, not the sidecar** — `queries/vault.ts:18-101`, `schema/vault.ts:10-32`, exported globally at `officerdb/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. 7. **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. 8. **Dead weight** — `router.ts:38-48` is a hand-written `GET /_health` passthrough the catch-all would cover for free. `proxy-util.ts:1-29` is a byte-for-byte duplicate of the sidecar's `stripHopByHop`/`redactPath` (`sidecar/vault/upstream.ts:33-58`), identical `HOP_BY_HOP` set and `REDACT` regex. The redaction exists only because the main process handles token-bearing query strings — fix (2) and it's unnecessary. 9. **Mounted outside the protected tree** — `hono.ts:73-77`. `route('/api/vault', …)` sits outside `protectedRouter`, so the router re-implements its own stack (`router.ts:31-33`: `originMiddleware`, `userMiddleware`, `ownerGate`). *Real constraint, probably why:* it deliberately avoids `bodyParser()` so bodies stream (`router.ts:14`), and `protectedRouter` would inherit it from `hono.ts:88` and buffer vault attachments. 10. **Stale comments on a security boundary** — `hono.ts:73-76` and `origin-validation.ts:36-39,61-64` both claim vault requests "carry their own Bitwarden bearer token, not a platform session JWT" and that `userMiddleware` would 401 them. Untrue since `router.ts:32-33` requires a valid platform JWT *and* owner status on every request. Also, `VAULT_AUTH_SPEC.md` (cited at `router.ts:12`, `schema/vault.ts:4`) and `BITWARDEN_SIDECAR_PROMPT.md` (cited at `sidecar/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. 1. **`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-435` encodes 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-589` owns stop semantics. `:443-518` is shared with opencode. 2. **`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 via `appendChatEvent` (`: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. 3. **`api/chat/claude-sessions.ts:1-361` — a reimplementation of Claude's transcript format.** The platform reads and *writes* `~/.claude/projects//.jsonl` directly: the slug encoding (`:39`), the entry schema (`:69-78`), content-block decoding (`:143-227`), listing (`:350-361`), delete-by-unlink (`:253-258`), a 32KB `readSync` plus a `"cwd":"…"` regex to recover a session's directory (`:294-306`), and — the sharpest example — **rename implemented by appending a `{type:'summary'}` entry and reconstructing `leafUuid`** (`:265-287`). The platform is writing into another program's private on-disk format. If the CLI changes that format, `officer` breaks. 4. **`api/tasks/pipeline-executor.ts:98-249, 585-586` — a second, independent interpreter.** Its own `refreshProxyToken()` POSTing to `127.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-586` silently coerces any model to `claude-code`. Two divergent implementations of the same protocol is the cost of not having a sidecar boundary. 5. **`sidecar-registry.ts:198-274` — the platform spawns the agent.** `ensureClaudeSidecar` / `spawnAndWaitForRegistration`: a per-email `Bun.spawn` of `user-instance.ts` with `stdout: 'inherit', stderr: 'inherit'` (`:240-241`), a `claudeProcs` Map, a `claudeSpawnWaiters` Map, a 15s timeout and a 50ms registration poll (`:259-267`). Plus the claude verbs at `:306-358` and a broadcast fallback at `:339-346`. `:88-90` uses `capabilities.includes('proxy')` as a stand-in for "is this the claude sidecar", which is only true by accident of naming. 6. **`generate-container-context.ts:135-182` (+ `:50-133`) — the platform writes the CLI's config.** It authors `~/.claude/settings.json`: a `Stop` hook curling `localhost:5000/api/hooks/claude-done` (`:144-153`), `defaultMode: 'bypassPermissions'` and `skipDangerousModePermissionPrompt: true` (`:155-156`), and pre-seeded `hasTrustDialogAccepted` (`:163-179`). Called from `users/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. 7. **`api/activity/router.ts:1-191` — the platform walks the agent's scratch tree.** Reads `/tmp/claude-//tasks/.output` (`:24-61`, keyed on `startsWith('claude-')` at `:34`) and tails it over SSE (`:116-191`). Another private layout the platform has learned. (`/announce`, `:103-113`, is unrelated and fine.) 8. **`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. 9. **`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.) 10. **`api/chat/list-models.ts:5-9` — the model catalogue is hardcoded in the platform:** `claude-code/opus|sonnet|haiku` with 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. 11. **`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`). 12. **`channels/send-claude-code.ts:1-68` — a third execution path**, running the SDK in-process, with a subscription-lifetime rule at `:53-58`. 13. **`sidecar/protocol.ts:14-19, 34-43, 68-103` — nine claude message types in the shared protocol,** including `ClaudeState` (`:70-73`, which exposes `proxySecret` and the whole session map) and `ClaudeSpawnParams` (`:77-96`). Domain vocabulary in what should be transport. 14. **Smaller, still real:** `channels/send-and-await.ts:6, 53, 75-78`; `hono.ts:79-86` — the `/api/hooks/claude-done` endpoint 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 from `bootstrap.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-` 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. 1. **`api/email/email-db.ts:1-746` — the entire mail store lives in the platform.** SQLite schema (`:9-55`), migrations with a `thread_id` backfill (`: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`. 2. **`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 in `officer` because `server.tsx:385` calls `initQueue()` → `queue/init.ts:6` → `handlers/index.ts:1-2`. So a full mailbox sync competes with request serving, and dies on restart. 3. **`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. 4. **`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 `:174` and `:366`. The `folder` value 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. 5. **`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`), and `performResync` (`:276-284`) which coalesces concurrent resyncs through an **in-process Map**. It is imported by both `sidecar/email/email-cron.ts:2` and `email-idle.ts:3` *and* by `accounts.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. 6. **`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. 7. **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. 8. **`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 at `integrations.ts:162-194` an independent Gmail REST proxy. `:15-19` requests the `https://mail.google.com/` scope — full mailbox access — and `:307-317` mutates the dock. 9. **`api/chat/websocket.ts:57-70` — `resolveEmailCwd` mkdirs email account directories** from inside the chat socket. Cross-domain reach: the chat path owns email's filesystem layout. 10. **`data-path.ts:44-50` — the platform owns the mail store's paths**, with a second consumer at `sidecar/claude/user-instance.ts:40, 75` (the `email_db` MCP tool). So three processes agree on a layout by convention rather than by asking one owner. 11. **Glue (routing only, would survive a proxy):** `hono.ts:34` + `:114`; `server.tsx:20` + `:85-89`; `protocol.ts:50-51` carries only `email:new`, which is correctly transport-shaped. 12. **`src/servers/sidecar/email-cron.ts` — 92 dead lines**, imported by nothing (the live one is `sidecar/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. 1. **`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`), the `metadata.officer` convention (`:179-182`), and a comment pinning it to **"opencode 1.17.9"** (`:170`) — a version dependency in `officer`'s source. **`:16-96` and `:108-139` are dead.** 2. **`api/chat/opencode/event-mapper.ts:1-140` — entirely dead.** The sidecar already maps events at `sidecar/opencode/runner.ts:150-205`. This is a stale duplicate of a sidecar concern. 3. **`api/server-settings/chat-providers.ts:1-632` — provider credentials handled in the platform.** Reads and writes `~/.pi/agent/auth.json` (`:10`, `:334-346`) and `models.json` (`:11`, `:61-73`), exposes API keys over GET/PUT (`:384-406`), and calls `api.anthropic.com`, `api.openai.com` and `opencode.ai/zen/v1/models` with raw keys (`:444-471`). `PROVIDERS` at `:353-367`; `invalidateModelCache()` at `:284, 293, 328, 404`; `:474-551` is stale `pi` install 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. 4. **`api/chat/opencode-sessions.ts:1-113` — transcript reconstruction** (`:50-82`), filtering on `metadata.officer.cwd` (`:21`), epoch→ISO conversion (`:29-30`), and a synthesised `opencode/` model string (`:84`). Same shape as `claude-sessions.ts`: the platform reading another program's session store. 5. **`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. 6. **`channels/send-opencode.ts:29-66` — the terminal-event set (`:33-37`) and a resume policy keyed on the `ses_` id prefix (`:42-45`).** Protocol knowledge encoded as a string prefix, in the platform. 7. **`api/chat/list-models.ts:2, 11-59` — fetches `/config/providers` and then invents capability metadata for the results (`:42-45`).** 8. **`api/chat/chat.ts:13-19, 44, 56-57, 68, 80-81, 91` — CRUD dispatch on `isOpenCodeSessionId`** (`opencode-sessions.ts:112`, a `startsWith('ses_')` test). 9. **`sidecar-registry.ts:360-392` — typed opencode RPC**, same anti-pattern as the claude verbs. 10. **`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. 11. **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-15` otherwise 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. 1. **`api/terminal/websocket.ts:1-169` — the whole file.** Broken out: - **`:59-68` — the platform builds the `PtyInitConfig`:** `process.env.SHELL ?? '/bin/zsh'`, `args: ['-i']`, and `host: true` **hardcoded** 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` — `resolveCwd` tilde expansion** in the platform. - **`:71-89` — per-session fan-out.** The platform subscribes to a **global** `pty:output` / `pty:exit` stream, filters by `msg.sessionId === sessionId`, and rewraps each frame. This is the hard part of any fix, and **the template already exists** at `server.tsx:164-228` (`devServerWebsocket` — a real byte relay). - **`:106-143` — inbound translation**, including `:135` **synthesizing 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 that **`pty:close` has 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, `nextId` and `sendOutput`.** - **`:158-169` — `broadcastPanelRefresh`**, a Claude concern parked in the terminal module; its only caller is `hono.ts:83` (the `claude-done` hook). 2. **A placement anomaly worth fixing on its own:** `ecosystem.config.cjs:27-32` is the only sidecar **outside `src/servers/sidecar/`**, the only **`.mjs`**, and the only one run by **`node`** rather than `bun`. It has zero relative imports (`pty-sidecar.mjs:13-19, 31`) and does **not** use `sidecar/connect.ts` — it carries **66 duplicated lines** (`:232-297`) including a byte-identical backoff table (`:37` vs `connect.ts:22`). Its types are JSDoc (`:39`), so `protocol.ts:145-156` is unenforced against it. The actual blocker to moving it is mundane: sibling `templates/` files on disk (`:27-29, 61, 66, 71` — `.zshrc`, `.tmux.conf`, `starship-officer.toml`, and an unused `.zshenv`). So a `git mv`, not a rewrite. *(Inferred: the `.mjs`/node choice is probably a node-pty native-addon workaround — corroborated by the comment at `api/cliamp/websocket.ts:100`.)* 3. **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-246` is correct. Identity ships **inside the payload** as `userLabel` / `sessionId` (`websocket.ts:56, 65`) instead of as `X-Officer-User`. 4. **`sidecar-registry.ts:395-407` plus PTY types threaded through generic plumbing** at `:12-13, 35, 93, 125, 153, 171, 180, 194`. One subtlety to preserve: the 30s `sendPtyCommandAsync` timeout at `websocket.ts:96` is load-bearing backpressure for `pty:ready`. 5. **`hono.ts` has no terminal router at all** — `terminal` appears only at `:42`. There is nothing to thin down; there is something to create. 6. **`data-path.ts` is bypassed:** terminal uses `process.env.HOME!` (`websocket.ts:63-64`) rather than `getOwnerHomeDir` (`data-path.ts:34`), unlike the eight other host-executing surfaces. Same result on this machine (`HOME_DIR` is set and equals `HOME`), 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. 1. **`api/desktop/websocket.ts:1-122` — session lifecycle plus a raw RFB TCP tunnel.** `startVnc` (`:21-25`), `Bun.connect` to the VNC port (`:35-37`), both pump directions (`:41`, `:103`), a `pendingMessages` buffer for pre-connect frames (`:9, 73-76, 97-100`), and close code `4004` (`:30, 67, 87`). **The blocker:** the vnc sidecar **exposes no HTTP or WS listener at all** — there is no `Bun.serve` anywhere in `sidecar/vnc/`, and no `vnc:server` port event, unlike `slskd:server` (`protocol.ts:64`), `vault:server` (`:62`) and `music: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. 2. **`sidecar-registry.ts:409-439` — five VNC verbs.** `stopVnc` (`:427`) has **no callers**. 3. **`sidecar/protocol.ts:23-28, 44-49, 115-129` — four commands and five events**, with `VncStartParams` (`:117-121`) and `VncSessionInfo` (`:123-129`) exposing `display` and `pid` to the platform — internal process detail crossing the boundary. 4. **`api/desktop/rest.ts:11-29` — a `/vnc-password` fallback ladder** in the platform, when the sidecar is already idempotent about this (`sidecar/vnc/index.ts:28-36` → `vnc-manager.ts:57`). `:31-37` also invents its own response envelope. 5. **`api/desktop/vnc-config.ts:1-10` — the main process reads `~/.vnc/password` in plaintext** (`:4, 7, 9`). The same layout is duplicated at `vnc-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 in `officer`. 6. **`server.tsx:309-312` — `/novnc/*` maps the request pathname onto `public/` with no auth and no traversal guard.** Flagging as observed, not diagnosed — whether it is exploitable depends on how `pathname` is 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. 7. Wiring at `server.tsx:13, 45, 149, 234, 339` is fine, and **`hono.ts:37, 122` is 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 | ✗ | | email | ~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:1` imports `ChatEvent` and `MessageCost` from `../api/chat/types`.** The transport layer depends on one feature's domain model. - **Nine `queue:*` message types are undeclared**, passed through by a `msg.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, **live** - `queue/engine.ts` + `queue/index.ts` — 283 lines, **dead** - `sidecar/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 restarting `officer-music` fails 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-90` treats `capabilities.includes('proxy')` as "is this claude"** — true only by accident of the naming confusion documented in `CLAUDE_SIDECAR_ISOLATION.md`. ### 6. What the database says (the clearest signal in the audit) Table ownership tracks compliance exactly: - **`queries/music.ts` and `queries/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 an `officer` restart lossy. - **`queries/email-accounts.ts` — split**, with `api/chat/websocket.ts:11, 61-62` reaching 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**: ```ts // useChat.ts:145-146 const seq = (data as { seq?: number }).seq; if (typeof seq === 'number' && seq > cursorRef.current) cursorRef.current = seq; ``` ```ts // 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 1. **Hardcoded model ids and provider names.** `state/src/useSettings.ts:86-87, 99` hardcodes `'claude-code'` three times as a default. `state/src/useModels.ts:68, 96` branches access policy on `m.provider === 'claude-code'` — twice. `Chat/components/ModelSelector.tsx:7-23` carries a `PROVIDER_DISPLAY` map of 15 provider ids. 2. **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. 3. **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 per `platform/CLAUDE.md`; the objection is only to where the decision lives — the browser is the furthest possible place from the sidecar that owns it.)* 4. **Capability metadata crosses to the client.** `Chat/types.ts:11-19` types `contextWindow`, `maxTokens` and `reasoning?`, and `ModelSelector.tsx:116` branches the UI on `reasoning`. 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 at `api/chat/list-models.ts:5-9`, so today the numbers originate two layers away from the thing they describe. 5. **Claude CLI session conventions are documented in the browser.** `state/src/useClaudeSessions.ts:8-9` comments that the id *is* the transcript filename; `SessionList.tsx:10-11` explains that clicking a session continues it "via --resume"; `:15` types `harness?: 'claude' | 'opencode'`. And the magic string **`'general_chat_sessions'`** — Claude's own default directory bucket — appears as a literal in `PwdSelector.tsx:7, 22, 62`, `ChatDetailPanel.tsx:86, 101` and `useClaudeSessions.ts:42`. 6. **`GET /chat/models` returns `hostHome`** (`useModels.ts:38`) — a host filesystem path handed to the browser. 7. **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. 8. **The event state machine is duplicated a third time — in the browser.** `Chat/types.ts:43-55` declares a 12-member `ServerMessage` union and `useChat.ts:148-277` switches on all of it, assembling deltas, tracking tool calls by `toolCallId`, and handling `task:started` / `task:notification`. Pass 1 found the same interpretation in `chat/websocket.ts:168-312` and again in `pipeline-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 `~/.claude` path 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-170` sums `inputTokens + outputTokens` and formats a `totalUSD` it was handed (`Chat/types.ts:5-9`). Correct placement. - **Lifecycle control is two logical verbs, not process control:** `stop` (turn) and `disconnect` (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). 1. **The browser owns IMAP connection parameters.** `EmailAccounts.tsx:21-37` holds `imapHost`, `imapPort` (default `'993'`) and `imapSecure` as first-class client state; `:82` hardcodes the Gmail preset `imap.gmail.com:993`; `:98-107` and `:124-133` POST `imapHost`/`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. 2. **MIME construction in the browser.** `Compose.tsx:218-236` clones the editor DOM, rewrites each inline `` to `cid:inline-N`, and ships the parts — the browser authoring the `cid:` URI scheme of a `multipart/related` message. 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 by `f.type.startsWith('image/')` (`:176`, `:195`). 3. **No HTML sanitisation of message bodies.** `EmailReader.tsx:19-53` writes server-supplied HTML directly into an iframe via `doc.write(...)`, with `sandbox="allow-same-origin"` and no `allow-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, and `allow-same-origin` without `allow-scripts` is a combination worth a deliberate second look rather than an accident. 4. **Two plaintext secrets are fetched into the browser and cached.** - `SMTPSection.tsx:39` — `GET /server-settings/smtp` returns the SMTP `password` (and Resend `apiKey`); `:63` writes it into state; `:208-213` binds it to a `type="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/config` returns `clientSecret` in plaintext; held in `useState` (`: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. 5. **The session bearer token is passed in a URL.** `EmailList.tsx:110-112` builds `new EventSource('/api/email/events?token=' + …)` from `localStorage`. Unavoidable for `EventSource` (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 at `useChat.ts:121-123`. 6. **The realtime channel cannot survive a restart — explicit, not inferred.** `EmailList.tsx:109-125` opens the SSE stream, expects `{ type: 'new-mail' }`, invalidates three query keys, and closes on unmount. There is **no `es.onerror`, no backoff, no reconnect, and no `Last-Event-ID` handling.** And the server never sends an `id:` field — Pass 1's `api/email/email.ts:101` emits only `data: {"type":"new-mail"}` — so even the browser's *native* `EventSource` retry cannot request replay. Any `new-mail` event 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. 7. **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 at `EmailAccounts.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 one `toast.error`. 8. **A client-side folder heuristic.** `EmailList.tsx:95-105`: if `inbox` returns zero but an `all` probe finds mail, the browser silently switches folder. Business logic about mailbox state, decided in the UI. 9. **Gmail knowledge, mostly as copy rather than behaviour.** `GoogleOAuthConfig.tsx:15-18` lists the OAuth scopes (`gmail.readonly`, `calendar.readonly`) and `:104-111`, `:116` explain Google's "restricted" classification — documentation text, not requests the browser makes. `EmailScreen.tsx:13` puts 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:241` knows Gmail files its own sent copy. ### Verified clean - **Threading is server-side.** The browser receives `EmailThread` pre-grouped (`EmailReader.tsx:130-134`, `types/email.ts:24-29`) and consumes precomputed `threadCount`/`threadUnread`. No participant computation, no subject normalisation, no dedup. (`Compose.tsx:385-393` does `Re:` prefixing for a reply draft — cosmetic.) - **Folder names are Officer's, not IMAP's.** `EmailList.tsx:35-41` is a fixed `inbox/all/sent/spam/trash` list passed as `folder=`; 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:132` and never read back — `GET /email/accounts` returns no credential field. OAuth tokens never reach the browser at all: `:88-107` either redirects the page to `/api/integrations/google/authorize` or POSTs `credentials: { 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-384` documents the absence, noting `m.id` is a local hash and that threading currently leans on `Re:` + 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/` 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`. 1. **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. 2. **Session UI is shared, not forked.** `SessionList.tsx` / `ChatDetailPanel.tsx` serve both harnesses off the same `/chat/pwds`, `/chat/sessions`, `/chat/sessions/:id`, `/chat/sessions/:id/title` routes, and the browser treats `id` as an **opaque string** — no format validation, no `ses_` regex, no branch on id shape. `ChatDetailPanel.tsx:96-98` resume/pagination is harness-agnostic. 3. **The WebSocket machinery is fully shared with no opencode branch.** `useChat.ts:121-123` builds one URL unconditionally; `hooks/src/useChatWebSocket.ts` takes only `{url, onMessage, onOpen}`; the 12-member `ServerMessage` union (`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. 4. **API keys are never echoed to the browser — verified against the server.** `AIHarnessesSection.tsx:57` GETs `/server-settings/chat-providers/api-keys`, but `api/server-settings/chat-providers.ts:384-391` masks 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 in `keyInputs` state (`:74`) and is **deleted after the PUT** (`:121-125`). Never in `localStorage`, `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.** 5. **No hardcoded model catalogue.** `state/src/useModels.ts:30-51` fetches everything from `/chat/models`. The only hardcoded data is display-name maps — `ModelSelector.tsx:7-23` (14 pairs) and a near-duplicate at `AISettings.tsx:19-34` (13 pairs, falling back to server-supplied `providerNames` via `useModels.ts:18-20`). Two overlapping copies of a label map is a small duplication, not a boundary violation. 6. **Naming debt, not coupling.** The shared hook is called `useClaudeSessions`, and `:9` comments `id: string; // Claude session uuid (= transcript filename)` while that field also carries opencode's `ses_…` ids; `:15` treats absent harness as claude; `:58` calls the store "Claude's own transcript store". Accurate when written, misleading now. 7. **No dead opencode frontend code.** Verified importers for all four files — `ModelSelector.tsx` (via `InputArea.tsx:92`, `ChatLauncher.tsx:107`, `TaskRunnerModal.tsx:202`), `SessionList.tsx` (routed at `App.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: ```ts // 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 ```ts // 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 1. **The browser composes shell commands by string concatenation, unescaped.** ```ts // Terminal.tsx:187-190 const wrapped = onCommandDoneRef.current ? `${commandRef.current}; echo "${EXIT_MARKER}$?__"` : commandRef.current; ws.send(JSON.stringify({ type: 'input', data: wrapped + '\r' })); ``` That assumes a POSIX shell (`;`, `$?`, `echo`) and does not escape `command`. Same pattern at `CommandTerminalWrapper.tsx:46-47`, which also does **tilde/home expansion in the browser** and builds `cd && `: ```ts const cwdPath = cwd && cwd !== '~' ? (cwd.startsWith('~') ? cwd : `~/${cwd.replace(/^\//, '')}`) : null; const fullCommand = cwdPath ? `cd ${cwdPath} && ${command}` : 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. 2. **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 by `l.includes(commandRef.current!.slice(0, 20))` at `:212`. Command completion is inferred by scraping terminal output for a marker the browser injected. 3. **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 the `cd` prefix and the exit-marker suffix above and typed into the pty as keystrokes. 4. No hardcoded shell **path** in the browser — `/bin/zsh` lives at `api/terminal/websocket.ts:62` (Pass 1, item 1a). Default cwd `'~'` comes from `WorkspaceContext.ts:34`. ### Two cross-checks that confirm Pass 1 findings - **The backend's `cwd` handler is unreachable.** Pass 1 flagged `api/terminal/websocket.ts:129-138` for synthesizing `` `cd ${JSON.stringify(msg.path)}\r` ``. Repo-wide grep finds **zero** frontend senders of `{type:'cwd'}` — the browser does its own `cd` composition instead (item 1 above). So that branch is dead, and the capability it implements is duplicated in the client. - **`detached` is dead in the other direction.** `Terminal.tsx:225-226` handles a `'detached'` message and writes `[Session taken over]`, but **no backend code ever emits it** — the only `detached` in `src/servers` is an unrelated field at `activity/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.** `exit` carries 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, 35` reuses the same component against `/api/cliamp/ws`** with a different `wsPath` — 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: 1. **`MUSIC_ROOT = 'Music'` hardcoded twice** — `shared.ts:4` and `widgets/MusicPlayer/index.tsx:12`. A home-relative prefix convention, not a filesystem path (no absolute path, no env var in the browser). Mild. 2. **The client re-sorts what the sidecar returns**, and says why: `shared.ts:63` comments that "meta.json is in ffprobe/readdir order (arbitrary)", so `sortTracks` (`: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. 3. **`AUDIO_EXT` duplicated verbatim** — `shared.ts:96` and `widgets/MusicPlayer/index.tsx:19` — a 9-extension allowlist used to filter raw `file-browser/ls` results. Plus an album-name convention regex (`ALBUM_NAME_RE`, `shared.ts:12`) for `"[year] Album Name"`. 4. **A playback design decision worth surfacing, though not a compliance issue.** `gapless-engine.ts:86-89` does a plain `fetch(url)` → `arrayBuffer()` → `decodeAudioData()` with **no `Range` header**, 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 by `CACHE_MAX = 3` (`:26`). So the byte-range support the API offers is never exercised by this path, and `X-Audio-Duration` is ignored in favour of `AudioBuffer.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=`; `CliampPanel.tsx:25` builds `/api/cliamp/ws?files=`; `: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**: ```ts // AudioStreamPlayer.tsx:9-10 const SAMPLE_RATE = 44100; const CHANNELS = 2; ``` - `s16le` is implied by `new Int16Array(ev.data)` (`:108`) and `int16[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 * 2` samples (`: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 | | email | ✗ ≈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/smtp` returns 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 of `IntegrationsSettings/index.tsx:11, 71-79`. - `officerdev/terminal-host` (`Terminal/index.tsx:47-54` + `HostTerminalWrapper.tsx`) — registered `availableOnPanel: 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` — the `cwd` handler; **no frontend sender exists** (the browser composes its own `cd`), 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: 1. **Closing a terminal panel leaks a shell.** `CommandTerminalWrapper.tsx:32-39` (and the two sibling wrappers) delete the `panelId → sessionId` mapping on unmount, so the next mount generates a new uuid and the detached shell becomes unreachable — with no kill path, since `pty:close` has no sender. 2. **Terminals don't re-fit after a resize.** `fitAddon.fit()` runs once per `connect()` (`Terminal.tsx:158`); there is no `ResizeObserver` or window listener, so dragging a splitter leaves the pty on stale dimensions until the next reconnect. 3. **opencode model capabilities shown to the user are placeholder constants.** `api/chat/list-models.ts:24` stubs every opencode model at `contextWindow: 200000, maxTokens: 8192, reasoning: false`, and `ModelSelector.tsx:116` hides the thinking toggle based on that `false`. --- **End of Pass 2.** Both passes are complete. Nothing in either document has been changed in code.