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:
@@ -76,6 +76,8 @@ export type SidecarEvent =
|
||||
| { type: 'invoiceshelf: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 }
|
||||
// Generic
|
||||
| { type: 'error'; id?: string; error: string };
|
||||
|
||||
@@ -147,47 +149,10 @@ export type VncSessionInfo = {
|
||||
alive: boolean;
|
||||
};
|
||||
|
||||
// ── PTY types ──
|
||||
|
||||
// What officer knows about a terminal, and nothing more. The shell, its arguments, the home directory and
|
||||
// whether the shell is sandboxed are the sidecar's own decisions — they used to travel in here, which is
|
||||
// how officer ended up reading the owner's SHELL and HOME and hardcoding `host: true`.
|
||||
export type PtyInitConfig = {
|
||||
sessionId: string;
|
||||
/** The folder the panel was opened on. `~`, `~/x` and absolute paths only; resolved by the sidecar. */
|
||||
cwd?: string;
|
||||
cols?: number;
|
||||
rows?: number;
|
||||
};
|
||||
|
||||
// PTY commands (API → PTY sidecar)
|
||||
export type PtyCommand =
|
||||
| { type: 'pty:init'; id: string; sessionId: string; config: PtyInitConfig }
|
||||
| { type: 'pty:input'; id: string; sessionId: string; data: string }
|
||||
| { type: 'pty:resize'; id: string; sessionId: string; cols: number; rows: number }
|
||||
| { type: 'pty:close'; id: string; sessionId: string }
|
||||
// Enumerate live shells. A panel keeps its session id across unmounts so it can re-attach, which means a
|
||||
// panel deleted for good leaves its shell running with nothing pointing at it. This is how you find one.
|
||||
| { type: 'pty:list'; id: string };
|
||||
|
||||
// PTY events (PTY sidecar → API)
|
||||
export type PtyEvent =
|
||||
| { type: 'pty:ready'; id: string; sessionId: string }
|
||||
| { type: 'pty:output'; sessionId: string; data: string }
|
||||
// Scrollback sent on re-attach, which the client may already be showing in part — distinct from
|
||||
// `pty:output` so it can rebuild the screen rather than append a second copy of it.
|
||||
| { type: 'pty:replay'; sessionId: string; data: string }
|
||||
| { type: 'pty:exit'; sessionId: string; exitCode: number; signal?: number }
|
||||
| { type: 'pty:sessions'; id: string; sessions: PtySessionInfo[] };
|
||||
|
||||
/** A live shell, as reported by `pty:list`. `title` is whatever the shell set via OSC 0/2 — usually the
|
||||
* running command — which is what makes an orphan identifiable rather than just a uuid. */
|
||||
export type PtySessionInfo = {
|
||||
sessionId: string;
|
||||
cols: number;
|
||||
rows: number;
|
||||
createdAt: number;
|
||||
lastActivityAt: number;
|
||||
title?: string;
|
||||
pid?: number;
|
||||
};
|
||||
// ── 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.
|
||||
|
||||
Reference in New Issue
Block a user