Files
platform/src/servers/sidecar/protocol.ts
T
pastilhasandClaude Opus 5 cb2dcfa4cc the notify sidecar stops trusting whoever reaches it
to send one message to four accounts an hour ago i found the sidecar's
ephemeral port and POSTed to it directly, once per user, with no
authentication of any kind. that worked, which was the problem: the same
door was open to anything else on the machine, including a commander with
a shell.

two changes.

POST /api/notify/broadcast, owner only via `ownerOnly` on the permission —
the supported way to say one thing to everyone. teams will narrow it later;
today the choice is everyone or one person.

and the sidecar now requires a secret on everything under /_officer/. it
is generated per boot, held in memory, announced to the platform over the
registration socket beside the port, and never written to disk.

X-Officer-User could not carry this on its own, and my first attempt got
that wrong: it says WHO is asking, and a caller on loopback can write it
itself. only the secret says the PLATFORM is asking. so the check covers
the whole /_officer/ surface rather than just the send route — /_officer/
devices takes the same header to decide whose phones are listed and
deleted.

what it bounds, honestly: a MEMBER's commander runs as its own Linux user
and cannot read this process, so for every account but the owner's this is
kernel-enforced. the owner's own commander runs as the owner, same as the
platform, and is not bounded by this and cannot be — the same reason
`execution` permissions are never grantable. the owner's decision, and
this is a real improvement rather than a boundary.

`bun run notify:test` posts to that port and will now be refused. it is a
developer CLI on a box where the platform is running; left to fail loudly
rather than given a way in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-24 16:29:48 +00:00

307 lines
16 KiB
TypeScript

import type { MessageCost, PromptImage, TurnMessage } from '../api/chat/types';
// ── Envelope ──
export type SidecarMessage = SidecarCommand | SidecarEvent;
/**
* A session the agent is holding in memory right now — the ground truth about what is alive.
*
* `pendingTasks` is background work started but not yet notified, and it is the field worth having:
* together with `isGenerating` it is exactly what the agent's own idle GC consults before deciding a
* session may be collected, so a caller can tell "busy" from "merely open" the same way it does.
*/
export type LiveClaudeSession = {
sessionKey: string;
/**
* Claude's own session id — the transcript's filename — or null before the first turn reports one.
*
* NOT the same as `sessionKey`, which is officer's handle for the conversation. Only the agent holds
* the mapping (`setClaudeSession`/`getClaudeSession`), so anything that wants to find the transcript
* has to be told it here. Assuming the two were equal made every live row unnameable and unlinkable.
*/
claudeSessionId: string | null;
isGenerating: boolean;
pendingTasks: number;
};
/**
* An OpenCode turn in flight. Only ever the generating ones, which is why it carries neither
* `isGenerating` (always true) nor `pendingTasks` (no such concept on this harness).
*
* It deliberately carries no id for OpenCode's own `ses_…` session, and does not need to: the sidecar
* only ever knows its own `sessionKey`, while the `ses_…` is reported separately over `opencode:session`
* and recorded by officer in `api/chat/opencode/state.ts`. `/chat/live` joins the two on officer's side,
* which is where both halves already are — widening this type would mean sending the sidecar a fact it
* had told officer in the first place.
*/
export type LiveOpenCodeSession = {
sessionKey: string;
};
// ── Commands (API server → sidecar) ──
import type { ProvisionableUser } from 'officerdb';
export type SidecarCommand =
| { type: 'ping'; id: string }
// Sent unprompted to a plugin whose manifest declares `userProvisioning`, right after its registration
// ack and again whenever an account is created or removed. A push rather than a request, because the
// sidecar has no authenticated way to ask — and because the platform is the only side that knows when
// the answer changes.
| { type: 'users:list'; id: string; users: ProvisionableUser[] }
| { type: 'state:sync'; id: string }
// Proxy
| { type: 'proxy:secret'; id: string }
// Claude Code
| { type: 'claude:spawn'; id: string; params: ClaudeSpawnParams }
| { type: 'claude:spawn-streaming'; id: string; params: ClaudeSpawnStreamingParams }
// Every one of these carries `userId` for the same reason `ClaudeSpawnStreamingParams.member` does: the
// sidecar's session map is global and `sessionKey` arrives in a client message, so a command without an
// identity is a command that acts on whoever's session happens to match. Resolved by the platform from the
// authenticated request, never taken from the client.
| { type: 'claude:kill'; id: string; sessionKey: string; userId: number }
| { type: 'claude:interrupt'; id: string; sessionKey: string; userId: number }
| { type: 'claude:clear-session'; id: string; sessionKey: string; userId: number }
// Is a turn still running for this session? Only the process that owns the session can say, which is
// exactly why it is asked over the wire — see `isClaudeGenerating` in sidecar-registry.
| { type: 'claude:is-generating'; id: string; sessionKey: string; userId: number }
// Turn plan mode on or off on a session that is already live, without waiting for the next turn. The
// toggle is meant to be usable while reading the agent's last answer, which is exactly when no turn is
// in flight to carry it.
| { type: 'claude:set-plan-mode'; id: string; sessionKey: string; userId: number; enabled: boolean }
/**
* The user's answer to a plan the agent asked to exit with.
*
* This is the only command that resolves a promise the AGENT is blocked on: `canUseTool` is holding
* `ExitPlanMode` open inside the sidecar while this round-trips to a browser and back. `requestId`
* identifies which one — a session can only have a single plan outstanding today, but keying on the id
* means a stale answer from a reconnected client cannot resolve a newer plan.
*/
| {
type: 'claude:plan-decision';
id: string;
sessionKey: string;
userId: number;
requestId: string;
approve: boolean;
feedback?: string;
}
/**
* The user's answer to an `AskUserQuestion` the agent is blocked on.
*
* Sibling of `claude:plan-decision` and the second command that resolves a promise the AGENT is waiting
* on. `toolCallId` is the harness's own `tool_use_id`, which the client already has from `tool:start` —
* so a stale answer replayed by a reconnecting client lands on the question it was meant for, or on
* nothing at all, and can never resolve a different one.
*/
| {
type: 'claude:question-answer';
id: string;
sessionKey: string;
userId: number;
toolCallId: string;
answer: string;
}
// Which session key owns this transcript? The map lives on the agent's disk, so only it can answer —
// see `findClaudeSessionKey` in sidecar-registry, and `attach` in the chat socket for why it is asked.
| { type: 'claude:find-session'; id: string; claudeSessionId: string; userId: number }
// Everything the agent is holding right now. `claude:is-generating` answers for a session you can
// already name; this is for the case where officer has forgotten every name it had — its session
// records are in memory and die with `pm2 restart officer`, while the agent keeps running. Without it
// a live session is invisible until a browser happens to reconnect to it by id.
| { type: 'claude:list'; id: string; userId: number }
// OpenCode — drive a turn through the serve (POST /api/session/{id}/prompt), anchored to the chat cwd
// by a per-request location header. Was an `opencode run` subprocess until 2026-08-10.
| { type: 'opencode:run-streaming'; id: string; params: OpenCodeRunParams }
| { type: 'opencode:kill'; id: string; sessionKey: string }
// Which OpenCode turns are running right now. The counterpart of `claude:list`, and thinner for a
// reason: OpenCode has no warm session between turns, so there is nothing to report but the running
// ones — see `listRunningServeTurns`.
| { type: 'opencode:list'; id: string };
// ── Responses/Events (sidecar → API server) ──
/**
* A sidecar telling the platform where its HTTP server is listening.
*
* `<name>:server`, where the name is the sidecar's own — `music:server`, `pty:server`, and whatever a
* plugin nobody has written yet decides to call itself. Deliberately open: see the note in SidecarEvent.
*/
export type SidecarPortAnnouncement = {
type: `${string}:server`;
port: number;
/**
* A per-boot secret for callers INSIDE the platform that reach this sidecar directly rather than
* through its proxy — announced here, in memory on both sides, never written to disk.
*
* Optional because only a sidecar with a producer path to guard has one. `notify` is the first: its
* send route accepts a target user in the body, which loopback alone stopped being enough to protect
* once commanders could run a shell on the same machine.
*/
secret?: string;
};
export type SidecarEvent =
| { type: 'pong'; id: string }
| { type: 'state:sync'; id: string; state: ClaudeState }
| { type: 'proxy:secret'; id: string; secret: string }
// Claude Code
| { type: 'claude:spawned'; id: string; sessionKey: string }
// A finished, browser-facing turn message. The agent has already committed it to chat_session_events
// and `seq` is its cursor id there; officer relays it verbatim. No `seq` means it is not durable —
// an `assistant:delta` (superseded by the text that follows) or a message whose write failed.
| { type: 'claude:message'; sessionKey: string; msg: TurnMessage; seq?: number }
// A lightweight, non-durable ping for notification delivery — deliberately NOT part of
// chat_session_events/TurnMessage. Officer decides whether to actually notify (preference lookup +
// fan-out to the browser); this only says "something notify-worthy just happened for this session."
| { type: 'claude:notify'; sessionKey: string; userId: number; kind: 'turn-ended' | 'input-needed' }
| { type: 'claude:result'; id: string; result: ClaudeCodeResult }
| { type: 'claude:error'; id: string; error: string }
| { type: 'claude:killed'; id: string }
| { type: 'claude:interrupted'; id: string }
| { type: 'claude:session-cleared'; id: string }
| { type: 'claude:generating'; id: string; generating: boolean }
| { type: 'claude:session-key'; id: string; sessionKey: string | null }
| { type: 'claude:sessions'; id: string; sessions: LiveClaudeSession[] }
| { type: 'opencode:sessions'; id: string; sessions: LiveOpenCodeSession[] }
// OpenCode — the sidecar reports where its `opencode serve` is listening (random port) on connect
| { type: 'opencode:server'; port: number }
// OpenCode turn streaming (analog of claude:*): spawned ack, per-message stream, session id report
| { type: 'opencode:spawned'; id: string; sessionKey: string }
// Same contract as `claude:message`: a finished turn message the sidecar has already committed to
// chat_session_events, plus the cursor id it landed on. Officer relays it and folds it into its
// in-memory transcript; it does not translate or persist.
| { type: 'opencode:message'; sessionKey: string; msg: TurnMessage; seq?: number }
| { type: 'opencode:session'; sessionKey: string; sessionId: string }
| { type: 'opencode:error'; id: string; error: string }
// Every HTTP sidecar's port announcement, as ONE member rather than sixteen.
//
// A sidecar binds an ephemeral loopback port and tells the platform over the registration socket; the
// proxy for its prefix subscribes to `<name>:server` and remembers the number. See create-proxy.ts.
//
// ── Why this is not a list of names ──
//
// It was: music, vault, slskd, headscale, transmission, invoiceshelf, jellyfin, photos, memos, gitea,
// caldav, wallet, pty, email and notify, each with an identical one-line entry. Two of those were for
// plugins that had already left the repository, which is the tell — the platform was declaring event
// types on behalf of code it no longer contains.
//
// The real cost was that a plugin could not announce its own port. `connection.send` takes a
// SidecarEvent, so `{ type: 'notes:server', port }` did not typecheck until somebody added a line
// HERE. A third-party plugin needed a pull request against the platform before its sidecar would
// compile — for the one message every HTTP sidecar has to send.
//
// Nothing was lost by generalising. The only consumer is `sidecar.on(\`${name}:server\`, …)` in
// create-proxy.ts, which already casts to read `.port` because a handler typed
// `(event: SidecarEvent) => void` cannot narrow from a computed template string. The sixteen entries
// bought a typo check on the SENDING side and nothing at the point of use — and two call sites had
// already cast past it (`as SidecarEvent` in notify, `as never` in create-proxy.test.ts).
//
// `[open]` That typo check is genuinely gone: a sidecar sending `muzik:server` while its proxy listens
// for `music:server` now compiles, and the symptom is every request answering 503 forever because the
// port is never learned. The fix is not to restore the list — it is for `createSidecarConnector` to
// announce the port itself from the `name` it already holds, so the string is written once instead of
// twice and the typo becomes unrepresentable. See create-proxy.ts.
| SidecarPortAnnouncement
// Generic
| { type: 'error'; id?: string; error: string };
// ── Claude sidecar state ──
export type ClaudeState = {
proxySecret: string;
claudeSessions: Record<string, { userId: number; claudeSessionId: string }>; // sessionKey → whose, and which transcript
};
// ── Param types ──
export type ClaudeSpawnParams = {
userId: number;
email: string;
username: string;
prompt: string;
sessionKey: string;
model?: string;
cwd?: string;
};
export type ClaudeSpawnStreamingParams = {
userId: number;
email: string;
username: string;
prompt: string;
images?: PromptImage[]; // sent as native image content blocks alongside the prompt text
sessionKey: string;
cwd?: string;
model?: string;
resumeSessionId?: string; // resume this Claude session uuid (from the /chat session list)
/**
* Run this turn in PLAN mode — research and propose, execute nothing until the plan is approved.
*
* Carried on the turn for the same reason `model` is: both are `query()` options read once at session
* creation, so both need a control request to change on a live session, and the turn is where the client's
* current intent arrives. Absent means "no opinion" and leaves a live session's mode alone; an explicit
* `false` turns planning off.
*/
planMode?: boolean;
// Whether turn output should be committed to chat_session_events (default true). A chat session wants
// it — that is what survives an officer restart. A pipeline step does not: its sessionKey is a throwaway
// uuid no browser will ever replay, and the job's own event log is its record.
durable?: boolean;
// Run this turn as a member's own Linux account, with their `claude` and their credential.
//
// Absent means the owner, which is every caller today: `api/chat/chat.ts` and the socket in `server.tsx`
// both refuse non-owners, so nothing can currently populate this. That is deliberate — the field exists so
// the sidecar side is testable and reviewable before the gates come off, not because anything sets it yet.
//
// Resolved by the platform from `resolveHomeDir`, NEVER taken from a client. Same rule as the pty sidecar,
// where `server.tsx` deletes any client-supplied `osUser`/`home` from the query string before setting its
// own — a member's identity is not a parameter they get to choose.
//
// REQUIRED, though it may be undefined. Optional (`member?:`) let a call site simply omit it, and one
// did: the "session already live" branch in `handleChat` built its own literal without it, so every turn
// after the sidecar's 30-minute idle timeout re-created the session as the OWNER. Spelling `undefined`
// is a decision; leaving the key out was an accident the compiler had no opinion about.
member: { osUser: string; home: string } | undefined;
};
export type ClaudeCodeResult = {
text: string;
sessionId: string;
model: string;
cost: MessageCost;
};
// ── OpenCode turn params ──
export type OpenCodeRunParams = {
sessionKey: string;
prompt: string;
cwd?: string; // passed to `opencode run --dir` — hard-re-anchors tools to this directory
model?: string; // `providerID/modelID` (e.g. opencode/claude-haiku-4-5); passed to --model verbatim
resumeSessionId?: string; // OpenCode `ses_…` id to continue (`--session`)
/**
* Images for this turn, base64 as the browser sent them.
*
* `opencode run` takes attachments as PATHS (`-f`), not inline data, so the sidecar writes each one to
* a temp file and deletes it when the turn ends. They travel as data because that is what crosses the
* websocket from the browser, and because officer and the sidecar are not guaranteed to share a disk.
*/
images?: PromptImage[];
durable?: boolean; // commit turn output to chat_session_events (default true) — see ClaudeSpawnStreamingParams
};
// The VNC command variants and their types left with the desktop plugin on 2026-08-21. A plugin's
// sidecar speaks its own vocabulary through `sendSidecarCommand`, which knows only that a command has a
// type and an id — see the note above it in sidecar-registry.ts.
// ── PTY ──
//
// Nothing but a port crosses this socket now. The pty sidecar serves its own HTTP + WebSocket listener and
// the browser reaches it through a byte relay, so there is no command vocabulary left: pty:init, :input,
// :resize, :close and :list all lived here until the sidecar owned its own transport, and officer filtered
// one global output stream per session to feed them. The port arrives as the shared `pty:server` event
// that createSidecarProxy already listens for.