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
307 lines
16 KiB
TypeScript
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.
|