step 4/4: the docs say permissions too, and capability means one thing again
44 files of prose — CLAUDE.md, AGENTS.md, TODO.md, 20 docs, both plugin design
documents, and the comment surface the earlier steps could not reach.
Applied against an explicit keep-list, not swept, because the word turned out to
have SIX meanings in this repository rather than the three the offscale doc
recorded:
permissions renamed (steps 1–2)
$OFFICER_ROOT/capabilities/ KEPT — the item store, and now the only thing
the word means that is ours
sidecar routing keys renamed to `handles` (step 3)
Lightning wallet KEPT — a domain term, and on the wire to the mobile apps
terminfo queries KEPT — XTGETTCAP, in the pty sidecar
InvoiceShelf KEPT — per-resource { write, bulkDelete } flags
The sweep still falsified two things, both caught by checking rather than by
review, and both in prose that discusses more than one meaning at once:
CLAUDE.md began claiming the item store lives at `$OFFICER_ROOT/permissions`.
It does not; that directory is on disk and full of skills and tools.
And the offscale doc's own note about the collision became
"Named `permissions`, NOT `permissions`" — a sentence that had eaten the thing
it existed to warn about.
Both restored, and the note rewritten to say what is now true: capability means
one thing of ours, and three that belong to somebody else's vocabulary.
Verified live after restart: self and admin permission endpoints 200, gated
route 200, agent-status 200, 9 grants intact with 6 permissions offered.
tsgo clean, 797 tests, 787 pass, same 7.
The rename is done. Four steps, no data lost, no client break that survived
the step it was introduced in.
This commit is contained in:
+140
-140
@@ -31,10 +31,10 @@ Three consequences worth stating explicitly, because the audit turned on the thi
|
||||
|
||||
`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. |
|
||||
| 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.
|
||||
@@ -49,24 +49,24 @@ Today's three commits (`8032c8b`, `b7b91a2`, `dea9ee2`) touched **zero** platfor
|
||||
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
|
||||
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 |
|
||||
| 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
|
||||
@@ -117,13 +117,13 @@ Under that line, the three files above are the work. The other six are a naming/
|
||||
|
||||
`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. |
|
||||
| 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`.
|
||||
|
||||
@@ -137,16 +137,16 @@ 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` |
|
||||
| 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
|
||||
|
||||
@@ -154,15 +154,15 @@ The eight sidecars, from `ecosystem.config.cjs`:
|
||||
**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** |
|
||||
| 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
|
||||
@@ -171,20 +171,20 @@ entirely in the main process, with no sidecar owning any of it.
|
||||
`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
|
||||
_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
|
||||
_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
|
||||
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
|
||||
@@ -231,25 +231,26 @@ sidecar owns, but it belongs to the file-browser contract.
|
||||
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) |
|
||||
| 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
|
||||
|
||||
- `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`,
|
||||
@@ -274,10 +275,10 @@ Two structural notes before the findings:
|
||||
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=<vw
|
||||
token>`. `open` (`:55-118`) verifies the platform JWT, fetches the upstream token, dials
|
||||
token>`. `open` (`:55-118`) verifies the platform JWT, fetches the upstream token, dials
|
||||
`ws://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
|
||||
_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`).
|
||||
@@ -290,7 +291,7 @@ Two structural notes before the findings:
|
||||
`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
|
||||
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`,
|
||||
@@ -299,7 +300,7 @@ Two structural notes before the findings:
|
||||
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
|
||||
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
|
||||
@@ -309,13 +310,13 @@ Two structural notes before the findings:
|
||||
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
|
||||
`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,
|
||||
`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.
|
||||
@@ -327,7 +328,7 @@ every `officerdb` vault export across `src/servers` and `src/databases`; the onl
|
||||
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`,
|
||||
_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
|
||||
@@ -342,7 +343,7 @@ The largest violation after email, and the one with the worst consequences, beca
|
||||
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,
|
||||
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
|
||||
@@ -363,7 +364,7 @@ than the owner of it. Every other item below is downstream of that.
|
||||
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/<slug>/<uuid>.jsonl` directly: the slug encoding
|
||||
platform reads and _writes_ `~/.claude/projects/<slug>/<uuid>.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
|
||||
@@ -378,7 +379,7 @@ than the owner of it. Every other item below is downstream of that.
|
||||
`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
|
||||
and a broadcast fallback at `:339-346`. `:88-90` uses `permissions.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
|
||||
@@ -387,7 +388,7 @@ than the owner of it. Every other item below is downstream of that.
|
||||
(`: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.
|
||||
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-<uid>/<encoded-cwd>/tasks/<id>.output` (`:24-61`, keyed on
|
||||
`startsWith('claude-')` at `:34`) and tails it over SSE (`:116-191`). Another private layout the
|
||||
@@ -419,7 +420,7 @@ than the owner of it. Every other item below is downstream of that.
|
||||
`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
|
||||
_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.
|
||||
@@ -428,7 +429,7 @@ process topology, the transport direction and the domain logic that are not.
|
||||
|
||||
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
|
||||
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.
|
||||
|
||||
@@ -460,7 +461,7 @@ Read plainly: the sidecar is a cron/IDLE trigger, and the platform is the mail c
|
||||
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
|
||||
`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
|
||||
@@ -486,7 +487,7 @@ Read plainly: the sidecar is a cron/IDLE trigger, and the platform is the mail c
|
||||
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
|
||||
_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.
|
||||
@@ -521,7 +522,7 @@ already does the same job. That makes this the cheapest of the non-compliant sur
|
||||
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
|
||||
7. **`api/chat/list-models.ts:2, 11-59` — fetches `/config/providers` and then invents permission
|
||||
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).
|
||||
@@ -573,11 +574,11 @@ already exists in the same codebase.
|
||||
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`.)*
|
||||
`.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
|
||||
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
|
||||
@@ -588,7 +589,7 @@ already exists in the same codebase.
|
||||
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,
|
||||
_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.
|
||||
@@ -625,7 +626,7 @@ pile of leaked logic.
|
||||
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 —
|
||||
_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
|
||||
@@ -640,19 +641,19 @@ eight times.
|
||||
|
||||
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 |
|
||||
| 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`).
|
||||
`: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.
|
||||
@@ -664,7 +665,7 @@ file-browser 1,465 · server-settings 1,452 · browser 1,105 · auth 607 · syst
|
||||
### 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
|
||||
sidecar permission requires editing a shared platform file — which is why domain knowledge keeps
|
||||
landing there (CLI flags, `display`/`pid`, `proxySecret`, spawn params).
|
||||
|
||||
Two specific consequences:
|
||||
@@ -675,7 +676,7 @@ Two specific consequences:
|
||||
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
|
||||
By contrast `registration-protocol.ts` (16 lines: `name` + `permissions: string[]`) is genuinely
|
||||
generic. The registration handshake got this right; the command channel did not.
|
||||
|
||||
### 3. Ten WebSocket providers, and only three are tunnels
|
||||
@@ -708,10 +709,10 @@ 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
|
||||
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
|
||||
- **`:88-90` treats `permissions.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)
|
||||
@@ -726,8 +727,8 @@ Table ownership tracks compliance exactly:
|
||||
- **`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.*
|
||||
**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.
|
||||
|
||||
---
|
||||
@@ -739,7 +740,7 @@ mirroring the slskd findings at the top of this document.
|
||||
|
||||
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.
|
||||
conventions, retry and reconnect policy, permission 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
|
||||
@@ -785,7 +786,7 @@ disconnected UI — a red "Disconnected" indicator (`ChatDetailPanel.tsx:38-52`)
|
||||
(`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
|
||||
`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.
|
||||
|
||||
@@ -807,16 +808,16 @@ check when the writer moves.
|
||||
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
|
||||
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`,
|
||||
browser is the furthest possible place from the sidecar that owns it.)_
|
||||
4. **Permission 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
|
||||
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;
|
||||
`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`,
|
||||
@@ -851,7 +852,7 @@ check when the writer moves.
|
||||
## 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
|
||||
`/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
|
||||
@@ -884,9 +885,10 @@ out of `IntegrationsSettings/index.tsx:11, 71-79`), `EmailScreen.tsx` (60), `typ
|
||||
- `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
|
||||
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
|
||||
@@ -895,7 +897,7 @@ out of `IntegrationsSettings/index.tsx:11, 71-79`), `EmailScreen.tsx` (60), `typ
|
||||
`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*
|
||||
`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
|
||||
@@ -925,18 +927,18 @@ out of `IntegrationsSettings/index.tsx:11, 71-79`), `EmailScreen.tsx` (60), `typ
|
||||
- **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.
|
||||
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.
|
||||
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`
|
||||
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
|
||||
@@ -971,7 +973,7 @@ it just always sends `cwd`.
|
||||
(`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
|
||||
`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)
|
||||
@@ -987,16 +989,16 @@ it just always sends `cwd`.
|
||||
(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 —
|
||||
`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
|
||||
branches the thinking toggle on `reasoning`). So the permission 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
|
||||
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.**
|
||||
|
||||
@@ -1056,9 +1058,9 @@ between `pty-sidecar.mjs:37` and `connect.ts:22` (Pass 1). Four backoff policies
|
||||
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
|
||||
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.)*
|
||||
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
|
||||
@@ -1072,9 +1074,7 @@ with an ephemeral `` `run-cmd-${Date.now()}` `` in local state — deliberate fo
|
||||
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;
|
||||
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
|
||||
@@ -1104,7 +1104,7 @@ with an ephemeral `` `run-cmd-${Date.now()}` `` in local state — deliberate fo
|
||||
- **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
|
||||
composition instead (item 1 above). So that branch is dead, and the permission 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
|
||||
@@ -1266,16 +1266,16 @@ settled before any of that code is moved: who is actually meant to talk to the v
|
||||
|
||||
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 |
|
||||
| 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
|
||||
@@ -1285,7 +1285,7 @@ because **the sidecar exposes Officer-shaped routes** — the `/_officer/*` name
|
||||
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
|
||||
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.
|
||||
|
||||
@@ -1294,13 +1294,13 @@ strictly worse — it is further from the data and unversioned.
|
||||
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 |
|
||||
| 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.
|
||||
@@ -1368,9 +1368,9 @@ Recorded because they surfaced during the audit, not because they're in scope:
|
||||
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.**
|
||||
3. **opencode model permissions 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`.
|
||||
reasoning: false`, and `ModelSelector.tsx:116` hides the thinking toggle based on that `false`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user