pty: the sidecar owns its own transport

Terminals were a set of commands the platform drove. Officer sent pty:init / pty:input /
pty:resize / pty:close / pty:list over the registration socket, subscribed to ONE global
output stream, filtered every frame down to a session and rewrapped it — double
JSON-encoded — on the way out. That is terminal knowledge living in the process whose job
is authentication, and it made officer part of the data path for every keystroke.

The sidecar now serves its own loopback HTTP + WebSocket listener and announces the port
as `pty:server`, like every other HTTP sidecar. Officer authenticates the upgrade and
relays frames without reading them.

Split into three files, because "the sidecar" was one:
- sessions.mjs — the shell store. Spawn, attach, detach, resize, kill, scrollback, the
  OSC-title scrape. Clients are a Set per session, so two panels can watch one shell.
- server.mjs — the listener. /ws speaks the browser's existing contract unchanged
  ({input,resize} in, {output,replay,exit,panel-refresh} out), plus /_officer/sessions,
  DELETE /_officer/sessions/:id and POST /_officer/panel-refresh.
- index.mjs — the registration socket, and nothing else. It carries a port now.

On the platform side /api/terminal/* becomes createSidecarProxy, deleting the hand-rolled
router from two days ago, and websocket.ts drops from a translating bridge to a byte relay
modelled on the vault one. The whole PtyCommand/PtyEvent/PtyInitConfig/PtySessionInfo
vocabulary is gone from protocol.ts, connect.ts and sidecar-registry.ts.

broadcastPanelRefresh is now a POST to the sidecar: officer no longer holds terminal
sockets to loop over. Fire-and-forget — a missed refresh is a stale panel, not a failure.

The frontend did not move. The sidecar speaks what the browser already spoke.

The integration test was rewritten against the new shape, and tests something stronger than
before: officer is stopped mid-session and the shell keeps streaming, because officer is
not in the path at all. It also covers re-attach replay, the session list and kill.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-31 10:54:35 +00:00
co-authored by Claude Opus 5
parent fccf212fe5
commit 7129cd82e6
11 changed files with 534 additions and 477 deletions
-32
View File
@@ -1,32 +0,0 @@
import { createRouter } from '../../create-router';
import { sendPtyCommandAsync, sendPtyCommand, isTerminalConnected } from '@@/sidecar-registry';
// Same shape as the bridge's own counter next door — correlation ids only need to be unique per process.
let idCounter = 0;
const nextId = (): string => `pty_${Date.now()}_${++idCounter}`;
// Live shells, and a way to kill one.
//
// A terminal panel keeps its session id across unmounts so reopening it re-attaches to the shell you left
// running (TerminalWrapper). The cost of that is a panel deleted for good leaves its shell alive with
// nothing pointing at it — `pty:close` has a handler but, until this router, had no sender at all. These
// two endpoints are how an orphan becomes visible and killable instead of just leaking.
export const terminalRouter = createRouter();
// GET /terminal/sessions → { sessions: PtySessionInfo[] }
terminalRouter.get('/sessions', async (ctx) => {
if (!isTerminalConnected()) return ctx.json({ sessions: [] });
const res = await sendPtyCommandAsync({ type: 'pty:list', id: nextId() });
if (res.type !== 'pty:sessions') return ctx.json({ error: 'unexpected response' }, 502);
return ctx.json({ sessions: res.sessions });
});
// DELETE /terminal/sessions/:sessionId — kills the shell. Fire-and-forget: the sidecar answers the death
// on the `pty:exit` broadcast that any attached client is already listening to, not as a reply here.
terminalRouter.delete('/sessions/:sessionId', (ctx) => {
if (!isTerminalConnected()) return ctx.json({ error: 'terminal sidecar not available' }, 503);
sendPtyCommand({ type: 'pty:close', id: nextId(), sessionId: ctx.req.param('sessionId') });
return ctx.json({ ok: true });
});
@@ -0,0 +1,13 @@
import { createSidecarProxy } from '../../sidecar/create-proxy';
// `/api/terminal/*` is a plain auth-and-forward proxy onto the pty sidecar's own listener, like every
// other HTTP sidecar. The sidecar owns `/_officer/sessions` (list), `DELETE /_officer/sessions/:id` (kill)
// and `POST /_officer/panel-refresh`; the platform knows none of those contracts, only where to send them.
//
// `/api/terminal/ws` does NOT come through here — Bun's route table takes it first and hands it to the
// byte relay in `websocket.ts`.
const proxy = createSidecarProxy({ name: 'pty', prefix: '/api/terminal' });
export const terminalRouter = proxy.router;
export const getTerminalHttpUrl = proxy.getHttpUrl;
export const getTerminalWsUrl = proxy.getWsUrl;
+74 -108
View File
@@ -1,138 +1,104 @@
import type { ServerWebSocket } from 'bun';
import { sendPtyCommand, sendPtyCommandAsync, on, isTerminalConnected } from '@@/sidecar-registry';
import type { PtyInitConfig } from '../../sidecar/protocol';
import { getTerminalWsUrl, getTerminalHttpUrl } from './sidecar-server';
type WSData = {
userId: number;
email: string;
username: string;
sessionId?: string;
cwd?: string;
cols?: number;
rows?: number;
// Byte relay between the browser and the pty sidecar's own socket.
//
// This used to be a translating bridge: officer sent pty:init / pty:input / pty:resize over the
// registration socket, subscribed to ONE global output stream, filtered every frame down to this session
// and rewrapped it — double-JSON-encoded — on the way out. All of that was terminal knowledge living in a
// process whose job is authentication. The sidecar serves its own socket now; this file authenticates the
// upgrade (upgradeWs, in server.tsx) and moves frames without reading them.
//
// The frame contract with the browser is unchanged, because the sidecar speaks it directly.
export type TerminalWSData = {
provider: 'terminal';
/** Raw query string from the upgrade — sessionId, cwd, cols, rows — passed through untouched. */
search: string;
};
type BridgeSession = {
client: ServerWebSocket<WSData>;
sessionId: string;
unsubs: Array<() => void>;
type UpstreamState = {
ws: WebSocket | null;
queue: (string | Uint8Array<ArrayBuffer>)[];
closed: boolean;
};
const sessions = new Map<ServerWebSocket<WSData>, BridgeSession>();
const upstreams = new Map<ServerWebSocket<TerminalWSData>, UpstreamState>();
let idCounter = 0;
function nextId(): string {
return `pty_${Date.now()}_${++idCounter}`;
}
const asPayload = (raw: string | Buffer): string | Uint8Array<ArrayBuffer> =>
typeof raw === 'string' ? raw : (raw as Uint8Array<ArrayBuffer>);
const sendOutput = (ws: ServerWebSocket<WSData>, data: string) => {
const closeClient = (ws: ServerWebSocket<TerminalWSData>, code: number, reason: string) => {
upstreams.delete(ws);
try {
ws.send(JSON.stringify({ type: 'output', data }));
ws.close(code, reason);
} catch {
// ws already closed
/* already closed */
}
};
export const terminalWebsocket = {
async open(ws: ServerWebSocket<WSData>) {
const { email, username } = ws.data;
open(ws: ServerWebSocket<TerminalWSData>) {
// Registered synchronously so frames that arrive before the upstream is ready are queued, not dropped.
const state: UpstreamState = { ws: null, queue: [], closed: false };
upstreams.set(ws, state);
console.log(`[terminal] open: email=${email} username=${username}`);
if (!isTerminalConnected()) {
sendOutput(ws, '\r\n[Terminal error] PTY sidecar is not connected\r\n');
const base = getTerminalWsUrl();
if (!base) {
try {
ws.send(JSON.stringify({ type: 'output', data: '\r\n[Terminal error] PTY sidecar is not connected\r\n' }));
} catch {
/* ignore */
}
closeClient(ws, 1011, 'pty sidecar unavailable');
return;
}
const sessionId = ws.data.sessionId ?? `host-${ws.data.userId}`;
const upstream = new WebSocket(`${base}/ws${state.closed ? '' : ws.data.search}`);
state.ws = upstream;
// Everything this bridge knows: which session, which folder the panel was opened on, and how big the
// client's viewport is. The shell, its arguments and the home directory are the sidecar's — it is the
// process that spawns them, and officer has no business reading the owner's SHELL and HOME to guess.
const config: PtyInitConfig = { sessionId, cwd: ws.data.cwd, cols: ws.data.cols, rows: ws.data.rows };
// The sidecar emits one global stream, so each frame is filtered down to this session and relabelled.
const relay = (event: 'pty:output' | 'pty:replay' | 'pty:exit', clientType: string) =>
on(event, (msg) => {
if (msg.type !== event || msg.sessionId !== sessionId) return;
try {
ws.send(JSON.stringify({ type: clientType, data: 'data' in msg ? msg.data : undefined }));
} catch {
// ws already closed
}
});
const session: BridgeSession = {
client: ws,
sessionId,
unsubs: [relay('pty:output', 'output'), relay('pty:replay', 'replay'), relay('pty:exit', 'exit')],
upstream.onopen = () => {
for (const frame of state.queue) upstream.send(frame);
state.queue.length = 0;
};
sessions.set(ws, session);
// Send init command to PTY sidecar
try {
await sendPtyCommandAsync({ type: 'pty:init', id: nextId(), sessionId, config });
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to initialize terminal';
sendOutput(ws, `\r\n[Terminal error] ${message}\r\n`);
for (const unsub of session.unsubs) unsub();
sessions.delete(ws);
}
},
message(ws: ServerWebSocket<WSData>, raw: string | Buffer) {
const session = sessions.get(ws);
if (!session) return;
try {
const payload = typeof raw === 'string' ? raw : raw.toString();
const msg = JSON.parse(payload);
switch (msg.type) {
case 'input':
sendPtyCommand({ type: 'pty:input', id: nextId(), sessionId: session.sessionId, data: msg.data ?? '' });
break;
case 'resize':
if (msg.cols > 0 && msg.rows > 0) {
sendPtyCommand({
type: 'pty:resize',
id: nextId(),
sessionId: session.sessionId,
cols: msg.cols,
rows: msg.rows,
});
}
break;
// There was a 'cwd' case here that typed `cd <path>\r` into the user's shell. No frontend sends
// that message — the browser composes its own `cd` (Terminal.tsx / CommandTerminalWrapper.tsx) —
// so it was unreachable, and synthesizing keystrokes is not a thing a proxy should do.
upstream.onmessage = (ev) => {
try {
ws.send(typeof ev.data === 'string' ? ev.data : new Uint8Array(ev.data as ArrayBuffer));
} catch {
/* client gone */
}
} catch {
// ignore malformed messages
}
};
upstream.onclose = () => closeClient(ws, 1000, 'upstream closed');
upstream.onerror = () => closeClient(ws, 1011, 'upstream error');
},
close(ws: ServerWebSocket<WSData>) {
const session = sessions.get(ws);
if (session) {
for (const unsub of session.unsubs) unsub();
// Don't kill PTY — it can be reattached
sessions.delete(ws);
message(ws: ServerWebSocket<TerminalWSData>, raw: string | Buffer) {
const state = upstreams.get(ws);
if (!state) return;
const payload = asPayload(raw);
if (state.ws && state.ws.readyState === WebSocket.OPEN) state.ws.send(payload);
else state.queue.push(payload);
},
close(ws: ServerWebSocket<TerminalWSData>) {
const state = upstreams.get(ws);
upstreams.delete(ws);
// Closing the upstream detaches this viewer; the shell stays alive in the sidecar, re-attachable.
try {
state?.ws?.close();
} catch {
/* ignore */
}
},
drain() {},
};
export const broadcastPanelRefresh = (email: string) => {
const msg = JSON.stringify({ type: 'panel-refresh' });
for (const [ws] of sessions) {
if (ws.data.email === email) {
try {
ws.send(msg);
} catch {
/* ignore */
}
}
}
// The claude-done hook asks attached terminals to refresh their panel. The sidecar holds those sockets
// now, so this is a POST to it rather than a loop over sockets officer used to own. Fire-and-forget: a
// missed refresh is a stale panel, not a failure worth surfacing.
export const broadcastPanelRefresh = (_email: string): void => {
const base = getTerminalHttpUrl();
if (!base) return;
fetch(`${base}/_officer/panel-refresh`, { method: 'POST' }).catch(() => {});
};