wraps the self-hosted memos instance, same shape as transmission and slskd. no schema change was needed: service_connections already says `service` is text because "adding a service should not be a schema change", and memos is the one-instance-per-owner case that table was built for. the sidecar holds the url and the personal access token; the platform side is 16 lines of createSidecarProxy and holds neither. /_api/* is a pass-through onto the instance's own /api/v1 rather than a hand-written wrapper per endpoint — memos generates its rest api from protobufs and it moves between minor versions, so re-describing it here would be a second thing to keep in sync. the allow-list is the one piece of policy, and it keeps this from being a general ssrf hop. auth routes are excluded: signin/signout would mint sessions on the instance, and this authenticates with a stored token. probing is two calls on purpose. /healthz answers unauthenticated, so a bad url is distinguishable from a bad token — memos returns 200 and an empty list for unauthenticated reads rather than 401, so "the list came back" proves nothing. verified against the live container: unconfigured reports not-connected, a bad token is rejected WITH the reason and nothing is stored, and the platform mount 401s without a session. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
167 lines
7.6 KiB
TypeScript
167 lines
7.6 KiB
TypeScript
import type { MessageCost, TurnMessage } from '../api/chat/types';
|
|
|
|
// ── Envelope ──
|
|
|
|
export type SidecarMessage = SidecarCommand | SidecarEvent;
|
|
|
|
// ── Commands (API server → sidecar) ──
|
|
|
|
export type SidecarCommand =
|
|
| { type: 'ping'; id: string }
|
|
| { 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 }
|
|
| { type: 'claude:kill'; id: string; sessionKey: string }
|
|
| { type: 'claude:interrupt'; id: string; sessionKey: string }
|
|
| { type: 'claude:clear-session'; id: string; sessionKey: string }
|
|
// OpenCode — drive a turn via `opencode run … --format json` (tools re-anchored to cwd via --dir)
|
|
| { type: 'opencode:run-streaming'; id: string; params: OpenCodeRunParams }
|
|
| { type: 'opencode:kill'; id: string; sessionKey: string }
|
|
// VNC
|
|
| { type: 'vnc:start'; id: string; params: VncStartParams }
|
|
// Provision the VNC password without starting a server — the UI needs it before it can connect
|
|
| { type: 'vnc:ensure-password'; id: string; email: string }
|
|
| { type: 'vnc:stop'; id: string; email: string };
|
|
|
|
// ── Responses/Events (sidecar → API server) ──
|
|
|
|
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 }
|
|
| { 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 }
|
|
// VNC
|
|
| { type: 'vnc:started'; id: string; port: number; display: number }
|
|
| { type: 'vnc:password'; id: string; password: string }
|
|
| { type: 'vnc:stopped'; id: string }
|
|
| { type: 'vnc:error'; id: string; error: string }
|
|
// Email — new mail no longer crosses this socket; the sidecar owns the SSE stream and pushes directly
|
|
// 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 }
|
|
// Music — the sidecar reports where its audio-streaming HTTP server is listening (random port) on connect
|
|
| { type: 'music:server'; port: number }
|
|
// Vault — the sidecar reports where its Vaultwarden reverse-proxy HTTP/WS server is listening on connect
|
|
| { type: 'vault:server'; port: number }
|
|
// slskd — the sidecar reports where its slskd reverse-proxy HTTP server is listening (random port) on connect
|
|
| { type: 'slskd:server'; port: number }
|
|
// Headscale — the sidecar reports where its HTTP server is listening (random port) on connect
|
|
| { type: 'headscale:server'; port: number }
|
|
// Transmission — the sidecar reports where its HTTP server is listening (random port) on connect
|
|
| { type: 'transmission:server'; port: number }
|
|
// InvoiceShelf — the sidecar reports where its HTTP server is listening (random port) on connect
|
|
| { type: 'invoiceshelf:server'; port: number }
|
|
// Photos (Immich) — the sidecar reports where its HTTP server is listening (random port) on connect
|
|
| { type: 'photos:server'; port: number }
|
|
// Memos — the sidecar reports where its HTTP server is listening (random port) on connect
|
|
| { type: 'memos:server'; port: number }
|
|
// CalDAV/CardDAV — the sidecar reports where its HTTP server is listening (random port) on connect.
|
|
// One port serves both doors: /dav (forwarded to Radicale) and /_officer (JSON for Officer's UI).
|
|
| { type: 'caldav:server'; port: number }
|
|
// Wallet — the sidecar reports where its HTTP server is listening (random port) on connect
|
|
| { type: 'wallet:server'; port: number }
|
|
// PTY — the sidecar reports where its terminal HTTP/WS server is listening (random port) on connect
|
|
| { type: 'pty:server'; port: number }
|
|
// Email — the sidecar reports where its mail HTTP server is listening (random port) on connect
|
|
| { type: 'email:server'; port: number }
|
|
// Notify — the sidecar reports where its notification HTTP server is listening (random port) on connect
|
|
| { type: 'notify:server'; port: number }
|
|
// Generic
|
|
| { type: 'error'; id?: string; error: string };
|
|
|
|
// ── Claude sidecar state ──
|
|
|
|
export type ClaudeState = {
|
|
proxySecret: string;
|
|
claudeSessions: Record<string, string>; // sessionKey → Claude Code session_id
|
|
};
|
|
|
|
// ── 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;
|
|
sessionKey: string;
|
|
cwd?: string;
|
|
model?: string;
|
|
resumeSessionId?: string; // resume this Claude session uuid (from the /chat session list)
|
|
// 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;
|
|
};
|
|
|
|
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`)
|
|
durable?: boolean; // commit turn output to chat_session_events (default true) — see ClaudeSpawnStreamingParams
|
|
};
|
|
|
|
// ── VNC types ──
|
|
|
|
export type VncStartParams = {
|
|
email: string;
|
|
username: string;
|
|
resolution?: string;
|
|
};
|
|
|
|
export type VncSessionInfo = {
|
|
email: string;
|
|
display: number;
|
|
port: number;
|
|
pid: number;
|
|
alive: boolean;
|
|
};
|
|
|
|
// ── 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.
|