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:
@@ -1,225 +1,45 @@
|
||||
// Graceful shutdown
|
||||
process.on('SIGINT', () => {
|
||||
console.log('[pty-sidecar] shutting down...');
|
||||
for (const [id, session] of sessions) {
|
||||
try { session.term.kill(); } catch { /* ignore */ }
|
||||
store.killAll();
|
||||
if (ws) {
|
||||
try {
|
||||
ws.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
sessions.clear();
|
||||
if (ws) { try { ws.close(); } catch { /* ignore */ } }
|
||||
process.exit(0);
|
||||
});
|
||||
process.on('SIGTERM', () => process.emit('SIGINT'));
|
||||
|
||||
import { join } from 'node:path';
|
||||
import WebSocket from 'ws';
|
||||
import * as pty from 'node-pty';
|
||||
import * as store from './sessions.mjs';
|
||||
import { startServer } from './server.mjs';
|
||||
|
||||
import 'dotenv/config';
|
||||
|
||||
const API_URL = process.env.API_URL ?? `ws://127.0.0.1:${process.env.PORT ?? '5000'}`;
|
||||
const REGISTER_URL = `${API_URL}/api/sidecar/register`;
|
||||
|
||||
// What a re-attaching client gets back. 50KB was about one long agent turn, so reconnecting mid-task
|
||||
// showed you the tail and nothing else. Per session, so ten live shells is ~5MB — cheap next to node-pty.
|
||||
const BUFFER_MAX = 512 * 1024;
|
||||
const RECONNECT_DELAYS = [200, 500, 1000, 2000, 4000, 8000, 15000];
|
||||
|
||||
// ── What kind of shell this process runs ──
|
||||
// This file is now only the registration socket.
|
||||
//
|
||||
// These used to arrive inside every pty:init, which meant officer chose the owner's shell and read the
|
||||
// owner's HOME to do it. They are this process's business: it is the one that spawns the thing.
|
||||
//
|
||||
// HOME_DIR mirrors `data-path.ts:getOwnerHomeDir` — on a host where the owner's real login home differs
|
||||
// from this process's HOME, the shell should open in the former, like every other host-executing surface.
|
||||
const HOME_DIR = process.env.HOME_DIR ?? process.env.HOME ?? process.cwd();
|
||||
const SHELL = { command: process.env.SHELL ?? '/bin/zsh', args: ['-i'] };
|
||||
// A terminal is always a plain host shell: the owner is the only account and it is their own machine
|
||||
// (`platform/CLAUDE.md` — do not add a jail without being asked). There used to be a second branch here
|
||||
// for a bwrap sandbox, selected by `config.host`, which officer hardcoded to true. Nothing ever built the
|
||||
// bwrap command it expected, on either side, so it could not have run — it is in git history if the
|
||||
// decision is ever revisited, and the `ensureUserFiles` half of it duplicated
|
||||
// `api/users/provision.ts:seedShellConfigs`, which is the live seeder of those templates.
|
||||
// It used to be the whole sidecar: the platform sent pty:init / pty:input / pty:resize / pty:close, and
|
||||
// officer filtered one global output stream per session and rewrapped every frame, double-JSON-encoded.
|
||||
// Terminals live in `sessions.mjs` and are served over this process's own listener (`server.mjs`), which
|
||||
// the browser reaches through a byte relay. All that crosses this socket now is the port number.
|
||||
|
||||
const resolveCwd = (cwd) => {
|
||||
if (!cwd || cwd === '~') return HOME_DIR;
|
||||
if (cwd.startsWith('~/')) return join(HOME_DIR, cwd.slice(2));
|
||||
if (cwd.startsWith('/')) return cwd;
|
||||
// Relative paths have no meaning here — this process's cwd is the repo, not the user's folder.
|
||||
return HOME_DIR;
|
||||
};
|
||||
let serverPort = null;
|
||||
|
||||
/** @type {Map<string, { term: import('node-pty').IPty, buffer: string, cols: number, rows: number }>} */
|
||||
const sessions = new Map();
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
// Always resolve the CURRENT registration socket, never one captured in a closure.
|
||||
//
|
||||
// `term.onData` used to close over the socket that was live when the session was created. Officer is a
|
||||
// PM2 peer that restarts often, and each restart gives this process a brand new socket — so every
|
||||
// pre-existing session went on writing to a closed one, where the readyState check below dropped it
|
||||
// silently. The shell stayed alive and kept accepting input (that arrives on the new socket), but its
|
||||
// output never came back: the terminal looked frozen until you closed the panel. Reading the module
|
||||
// variable at send time is the whole fix.
|
||||
const sendJson = (msg) => {
|
||||
try {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify(msg));
|
||||
}
|
||||
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const appendBuffer = (session, data) => {
|
||||
session.buffer += data;
|
||||
if (session.buffer.length > BUFFER_MAX) {
|
||||
// Cut on a line boundary, not a byte offset. A blind slice can land inside an escape sequence, and
|
||||
// the replay then opens with the tail of a colour or cursor-move code — which xterm renders as
|
||||
// garbage, or worse, applies as a real instruction. Fall back to the raw cut if there is no newline
|
||||
// in the last 4KB (a single enormous line), where a truncated sequence is the lesser problem.
|
||||
const cut = session.buffer.length - BUFFER_MAX;
|
||||
const nl = session.buffer.indexOf('\n', cut);
|
||||
session.buffer = nl !== -1 && nl - cut < 4096 ? session.buffer.slice(nl + 1) : session.buffer.slice(cut);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Command handler ──
|
||||
|
||||
async function handleCommand(msg) {
|
||||
switch (msg.type) {
|
||||
case 'pty:init': {
|
||||
const { sessionId, config } = msg;
|
||||
if (!sessionId) return;
|
||||
|
||||
const existing = sessions.get(sessionId);
|
||||
console.log(`[pty-sidecar] init sessionId=${sessionId} existing=${!!existing} total=${sessions.size}`);
|
||||
|
||||
if (existing) {
|
||||
// Re-attach. The scrollback goes out as `pty:replay`, not as ordinary output, because the client
|
||||
// may already be showing some of it: after an officer restart the browser keeps its terminal and
|
||||
// reconnects, so replaying blind appended a second copy of everything on screen. Marked as
|
||||
// history, the client can reset and rebuild from it instead.
|
||||
if (existing.buffer.length > 0) {
|
||||
sendJson({ type: 'pty:replay', sessionId, data: existing.buffer });
|
||||
}
|
||||
|
||||
// Resize PTY to new client dimensions
|
||||
const cols = config.cols ?? existing.cols;
|
||||
const rows = config.rows ?? existing.rows;
|
||||
if (cols > 0 && rows > 0 && (cols !== existing.cols || rows !== existing.rows)) {
|
||||
existing.cols = cols;
|
||||
existing.rows = rows;
|
||||
try {
|
||||
existing.term.resize(cols, rows);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
sendJson({ type: 'pty:ready', id: msg.id, sessionId });
|
||||
return;
|
||||
}
|
||||
|
||||
// New session — spawn PTY
|
||||
const cwd = resolveCwd(config.cwd);
|
||||
const cols = config.cols ?? 80;
|
||||
const rows = config.rows ?? 24;
|
||||
|
||||
let term;
|
||||
try {
|
||||
term = pty.spawn(SHELL.command, SHELL.args, {
|
||||
name: 'xterm-256color',
|
||||
cols,
|
||||
rows,
|
||||
cwd,
|
||||
// COLORTERM is how programs decide they may emit 24-bit colour — TERM only advertises 256.
|
||||
// xterm.js renders truecolor fine, so without this we were throwing away colour depth for
|
||||
// anything that checks (tmux with `*:RGB`, neovim, bat, delta, modern TUIs).
|
||||
env: { ...process.env, TERM: 'xterm-256color', COLORTERM: 'truecolor' },
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to start terminal';
|
||||
sendJson({ type: 'pty:output', sessionId, data: `\r\n[Terminal error] ${message}\r\n` });
|
||||
sendJson({ type: 'pty:exit', sessionId, exitCode: 1 });
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const session = { term, buffer: '', cols, rows, createdAt: now, lastActivityAt: now, title: '', pid: term.pid };
|
||||
sessions.set(sessionId, session);
|
||||
|
||||
term.onData((output) => {
|
||||
appendBuffer(session, output);
|
||||
session.lastActivityAt = Date.now();
|
||||
// Track the title the shell sets for itself (OSC 0/2 — usually the running command). Cheap to
|
||||
// scan for, and it is what turns "some uuid" into "the one running claude" in the session list.
|
||||
const titleMatch = /\x1b\][02];([^\x07\x1b]*)(?:\x07|\x1b\\)/.exec(output);
|
||||
if (titleMatch) session.title = titleMatch[1];
|
||||
sendJson({ type: 'pty:output', sessionId, data: output });
|
||||
});
|
||||
|
||||
term.onExit(({ exitCode, signal }) => {
|
||||
console.log(`[pty-sidecar] session ${sessionId} exited code=${exitCode} signal=${signal}`);
|
||||
sendJson({ type: 'pty:exit', sessionId, exitCode, signal });
|
||||
sessions.delete(sessionId);
|
||||
});
|
||||
|
||||
sendJson({ type: 'pty:ready', id: msg.id, sessionId });
|
||||
return;
|
||||
}
|
||||
|
||||
case 'pty:input': {
|
||||
const session = sessions.get(msg.sessionId);
|
||||
if (session) {
|
||||
session.term.write(msg.data ?? '');
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'pty:resize': {
|
||||
const session = sessions.get(msg.sessionId);
|
||||
if (session && msg.cols > 0 && msg.rows > 0) {
|
||||
session.cols = msg.cols;
|
||||
session.rows = msg.rows;
|
||||
try {
|
||||
session.term.resize(msg.cols, msg.rows);
|
||||
} catch {
|
||||
// PTY may have already exited
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'pty:list': {
|
||||
const list = [...sessions.entries()].map(([id, s]) => ({
|
||||
sessionId: id,
|
||||
cols: s.cols,
|
||||
rows: s.rows,
|
||||
createdAt: s.createdAt ?? 0,
|
||||
lastActivityAt: s.lastActivityAt ?? 0,
|
||||
title: s.title || undefined,
|
||||
pid: s.pid,
|
||||
}));
|
||||
sendJson({ type: 'pty:sessions', id: msg.id, sessions: list });
|
||||
return;
|
||||
}
|
||||
|
||||
case 'pty:close': {
|
||||
const session = sessions.get(msg.sessionId);
|
||||
if (session) {
|
||||
try {
|
||||
session.term.kill();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
sessions.delete(msg.sessionId);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Connect to API server with reconnect ──
|
||||
|
||||
let ws = null;
|
||||
@@ -243,18 +63,18 @@ function connect() {
|
||||
reconnectAttempt = 0;
|
||||
console.log('[pty-sidecar] connected, sending registration...');
|
||||
sendJson({ type: 'register', name: 'pty', capabilities: ['terminal'] });
|
||||
// Re-announce on every reconnect: officer forgets the port when the socket drops, and this process
|
||||
// keeps the same listener across officer restarts.
|
||||
if (serverPort) sendJson({ type: 'pty:server', port: serverPort });
|
||||
});
|
||||
|
||||
ws.on('message', (data) => {
|
||||
try {
|
||||
const msg = JSON.parse(typeof data === 'string' ? data : data.toString());
|
||||
|
||||
if (msg.type === 'registered') {
|
||||
console.log(`[pty-sidecar] registered with API server (id=${msg.id})`);
|
||||
return;
|
||||
if (serverPort) sendJson({ type: 'pty:server', port: serverPort });
|
||||
}
|
||||
|
||||
handleCommand(msg);
|
||||
} catch {
|
||||
// skip malformed messages
|
||||
}
|
||||
@@ -284,5 +104,8 @@ function scheduleReconnect() {
|
||||
}, delay);
|
||||
}
|
||||
|
||||
// Start connecting
|
||||
connect();
|
||||
// Listener first, so the port is known before the first registration goes out.
|
||||
startServer().then((port) => {
|
||||
serverPort = port;
|
||||
connect();
|
||||
});
|
||||
|
||||
@@ -1,23 +1,51 @@
|
||||
import { describe, test, expect, afterAll } from 'bun:test';
|
||||
import type { ServerWebSocket, Subprocess } from 'bun';
|
||||
|
||||
// Integration test for the one thing about the pty sidecar that cannot be reasoned about from the code
|
||||
// alone: what happens to a live shell when officer goes away and comes back. It stands up a fake
|
||||
// registration socket, runs the real sidecar against it, then kills the socket and rebinds the same port
|
||||
// the way `pm2 restart officer` does.
|
||||
// Integration test for the things about the pty sidecar that cannot be reasoned about from the code alone:
|
||||
// that it serves its own transport, that a shell outlives both its viewer and officer, and that re-attach
|
||||
// replays rather than re-spawns.
|
||||
//
|
||||
// Nothing here touches the running officer — the sidecar dials API_URL, which is overridden per spawn.
|
||||
// The fake officer exists only to receive the announced port — officer is not in the data path any more,
|
||||
// so the browser side is spoken directly to the sidecar's own listener. Nothing here touches the running
|
||||
// officer: the sidecar dials API_URL, overridden per spawn.
|
||||
|
||||
const SIDECAR = 'src/servers/sidecar/pty/index.mjs';
|
||||
|
||||
type Frame = Record<string, any>;
|
||||
|
||||
// One fake officer. `stop()` drops the socket; a new instance on the same port is the restart.
|
||||
function fakeOfficer(port?: number) {
|
||||
let socket: ServerWebSocket<unknown> | null = null;
|
||||
function collector() {
|
||||
const frames: Frame[] = [];
|
||||
const waiters: Array<{ match: (f: Frame) => boolean; resolve: (f: Frame) => void }> = [];
|
||||
|
||||
const push = (frame: Frame) => {
|
||||
frames.push(frame);
|
||||
for (let i = waiters.length - 1; i >= 0; i--) {
|
||||
if (waiters[i]!.match(frame)) waiters.splice(i, 1)[0]!.resolve(frame);
|
||||
}
|
||||
};
|
||||
|
||||
const await_ = (match: (f: Frame) => boolean, what: string, timeoutMs = 15_000) =>
|
||||
new Promise<Frame>((resolve, reject) => {
|
||||
const seen = frames.find(match);
|
||||
if (seen) return resolve(seen);
|
||||
const timer = setTimeout(() => reject(new Error(`timed out waiting for ${what}`)), timeoutMs);
|
||||
waiters.push({
|
||||
match,
|
||||
resolve: (f) => {
|
||||
clearTimeout(timer);
|
||||
resolve(f);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return { frames, push, await_ };
|
||||
}
|
||||
|
||||
/** A fake officer: just enough registration socket to receive `pty:server`. */
|
||||
function fakeOfficer(port?: number) {
|
||||
let socket: ServerWebSocket<unknown> | null = null;
|
||||
const { push, await_ } = collector();
|
||||
|
||||
const server = Bun.serve({
|
||||
port: port ?? 0,
|
||||
fetch(req, srv) {
|
||||
@@ -27,13 +55,10 @@ function fakeOfficer(port?: number) {
|
||||
websocket: {
|
||||
open(ws) {
|
||||
socket = ws;
|
||||
ws.send(JSON.stringify({ type: 'registered', id: 'sc_test' }));
|
||||
},
|
||||
message(_ws, raw) {
|
||||
const frame = JSON.parse(String(raw)) as Frame;
|
||||
frames.push(frame);
|
||||
for (let i = waiters.length - 1; i >= 0; i--) {
|
||||
if (waiters[i]!.match(frame)) waiters.splice(i, 1)[0]!.resolve(frame);
|
||||
}
|
||||
push(JSON.parse(String(raw)) as Frame);
|
||||
},
|
||||
close() {
|
||||
socket = null;
|
||||
@@ -43,49 +68,54 @@ function fakeOfficer(port?: number) {
|
||||
|
||||
return {
|
||||
port: server.port,
|
||||
send: (msg: Frame) => socket?.send(JSON.stringify(msg)),
|
||||
/** Resolve on the first frame matching `match`, including ones already received. */
|
||||
await: (match: (f: Frame) => boolean, timeoutMs = 15_000) =>
|
||||
new Promise<Frame>((resolve, reject) => {
|
||||
const seen = frames.find(match);
|
||||
if (seen) return resolve(seen);
|
||||
const timer = setTimeout(() => reject(new Error(`timed out waiting for a frame`)), timeoutMs);
|
||||
waiters.push({
|
||||
match,
|
||||
resolve: (f) => {
|
||||
clearTimeout(timer);
|
||||
resolve(f);
|
||||
},
|
||||
});
|
||||
}),
|
||||
/** Output frames for one session, concatenated — the terminal's visible text. */
|
||||
outputFor: (sessionId: string) =>
|
||||
frames
|
||||
.filter((f) => f.type === 'pty:output' && f.sessionId === sessionId)
|
||||
.map((f) => f.data as string)
|
||||
.join(''),
|
||||
await: (match: (f: Frame) => boolean, what: string) => await_(match, what),
|
||||
stop: () => server.stop(true),
|
||||
get connected() {
|
||||
return socket !== null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const isOutput = (sessionId: string, needle: string) => (f: Frame) =>
|
||||
f.type === 'pty:output' && f.sessionId === sessionId && String(f.data).includes(needle);
|
||||
/** A fake browser. The sidecar's socket contract IS the one the browser already speaks. */
|
||||
function client(wsBase: string, query: string) {
|
||||
const ws = new WebSocket(`${wsBase}/ws?${query}`);
|
||||
const { frames, push, await_ } = collector();
|
||||
|
||||
ws.addEventListener('message', (ev) => push(JSON.parse(String(ev.data)) as Frame));
|
||||
|
||||
return {
|
||||
open: () =>
|
||||
new Promise<void>((resolve) =>
|
||||
ws.readyState === WebSocket.OPEN ? resolve() : ws.addEventListener('open', () => resolve()),
|
||||
),
|
||||
send: (msg: Frame) => ws.send(JSON.stringify(msg)),
|
||||
await: (match: (f: Frame) => boolean, what: string) => await_(match, what),
|
||||
outputSoFar: () =>
|
||||
frames
|
||||
.filter((f) => f.type === 'output')
|
||||
.map((f) => String(f.data))
|
||||
.join(''),
|
||||
close: () => ws.close(),
|
||||
};
|
||||
}
|
||||
|
||||
const sawOutput = (needle: string) => (f: Frame) => f.type === 'output' && String(f.data).includes(needle);
|
||||
|
||||
let child: Subprocess | null = null;
|
||||
afterAll(() => child?.kill());
|
||||
|
||||
describe('pty sidecar', () => {
|
||||
test('a shell keeps streaming output after officer restarts under it', async () => {
|
||||
const sessionId = 'test-restart';
|
||||
test('serves its own transport; a shell outlives both its viewer and officer', async () => {
|
||||
const sessionId = 'test-transport';
|
||||
let officer = fakeOfficer();
|
||||
const port = officer.port;
|
||||
const officerPort = officer.port;
|
||||
|
||||
child = Bun.spawn(['node', SIDECAR], {
|
||||
env: {
|
||||
...process.env,
|
||||
API_URL: `ws://127.0.0.1:${port}`,
|
||||
// The sidecar now chooses the shell and the home itself, so the test pins both rather than
|
||||
// spawning the owner's interactive zsh (which would read their rc files and their history).
|
||||
API_URL: `ws://127.0.0.1:${officerPort}`,
|
||||
// The sidecar chooses the shell and the home itself, so pin both rather than spawning the owner's
|
||||
// interactive zsh, which would read their rc files and their history.
|
||||
SHELL: '/bin/sh',
|
||||
HOME_DIR: '/tmp',
|
||||
ENV: '/dev/null',
|
||||
@@ -94,41 +124,62 @@ describe('pty sidecar', () => {
|
||||
stderr: 'ignore',
|
||||
});
|
||||
|
||||
await officer.await((f) => f.type === 'register' && f.capabilities?.includes('terminal'));
|
||||
// The only thing that crosses the registration socket now.
|
||||
const announced = await officer.await((f) => f.type === 'pty:server' && typeof f.port === 'number', 'pty:server');
|
||||
const httpBase = `http://127.0.0.1:${announced.port}`;
|
||||
const wsBase = `ws://127.0.0.1:${announced.port}`;
|
||||
|
||||
// `~` is resolved by the sidecar against its own HOME_DIR, not by officer.
|
||||
officer.send({ type: 'pty:init', id: 'i1', sessionId, config: { sessionId, cwd: '~', cols: 80, rows: 24 } });
|
||||
await officer.await((f) => f.type === 'pty:ready' && f.sessionId === sessionId);
|
||||
const a = client(wsBase, `sessionId=${sessionId}&cwd=~&cols=80&rows=24`);
|
||||
await a.open();
|
||||
|
||||
officer.send({ type: 'pty:input', id: 'in0', sessionId, data: 'pwd\n' });
|
||||
await officer.await(isOutput(sessionId, '/tmp'));
|
||||
// `~` is resolved by the sidecar against its own HOME_DIR.
|
||||
a.send({ type: 'input', data: 'pwd\n' });
|
||||
await a.await(sawOutput('/tmp'), 'pwd output');
|
||||
|
||||
officer.send({ type: 'pty:input', id: 'in1', sessionId, data: 'echo before-restart\n' });
|
||||
await officer.await(isOutput(sessionId, 'before-restart'));
|
||||
a.send({ type: 'input', data: 'echo before-restart\n' });
|
||||
await a.await(sawOutput('before-restart'), 'before-restart');
|
||||
|
||||
// ── the restart ──
|
||||
// ── officer restarts ──
|
||||
// It is not in the data path, so this is a non-event for the shell. Under the old translating bridge
|
||||
// every keystroke and every byte of output crossed the registration socket.
|
||||
officer.stop();
|
||||
officer = fakeOfficer(port);
|
||||
await officer.await((f) => f.type === 'register');
|
||||
a.send({ type: 'input', data: 'echo during-outage\n' });
|
||||
await a.await(sawOutput('during-outage'), 'output while officer is down');
|
||||
|
||||
// The shell is the same process; only officer changed. Before the sendJson fix this input was
|
||||
// accepted and executed, but its output went to the socket captured at init time and vanished.
|
||||
officer.send({ type: 'pty:input', id: 'in2', sessionId, data: 'echo after-restart\n' });
|
||||
await officer.await(isOutput(sessionId, 'after-restart'));
|
||||
officer = fakeOfficer(officerPort);
|
||||
await officer.await((f) => f.type === 'pty:server', 're-announced port');
|
||||
|
||||
// Re-attaching replays the scrollback, marked as history so the client can rebuild rather than
|
||||
// append — and it contains what happened on both sides of the restart.
|
||||
officer.send({ type: 'pty:init', id: 'i2', sessionId, config: { sessionId, cols: 80, rows: 24 } });
|
||||
const replay = await officer.await((f) => f.type === 'pty:replay' && f.sessionId === sessionId);
|
||||
// ── the viewer goes away, a new one attaches ──
|
||||
a.close();
|
||||
const b = client(wsBase, `sessionId=${sessionId}&cols=80&rows=24`);
|
||||
await b.open();
|
||||
|
||||
// Re-attach replays scrollback as history rather than ordinary output, so the client rebuilds instead
|
||||
// of appending a second copy — and it spans everything, including what happened while officer was down.
|
||||
const replay = await b.await((f) => f.type === 'replay', 'replay');
|
||||
expect(String(replay.data)).toContain('before-restart');
|
||||
expect(String(replay.data)).toContain('after-restart');
|
||||
expect(String(replay.data)).toContain('during-outage');
|
||||
expect(b.outputSoFar()).not.toContain('before-restart');
|
||||
|
||||
// Re-attach must not spawn a second shell, and must not replay as ordinary output.
|
||||
await officer.await((f) => f.type === 'pty:ready' && f.id === 'i2');
|
||||
expect(officer.outputFor(sessionId)).not.toContain('before-restart');
|
||||
// Same shell, not a second one.
|
||||
b.send({ type: 'input', data: 'echo after-reattach\n' });
|
||||
await b.await(sawOutput('after-reattach'), 'after-reattach');
|
||||
|
||||
officer.send({ type: 'pty:close', id: 'c1', sessionId });
|
||||
await officer.await((f) => f.type === 'pty:exit' && f.sessionId === sessionId);
|
||||
// ── the sidecar's own HTTP surface ──
|
||||
const listed = (await fetch(`${httpBase}/_officer/sessions`).then((r) => r.json())) as { sessions: any[] };
|
||||
const entry = listed.sessions.find((s) => s.sessionId === sessionId);
|
||||
expect(entry).toBeTruthy();
|
||||
expect(entry.pid).toBeGreaterThan(0);
|
||||
expect(entry.clients).toBe(1);
|
||||
|
||||
const killed = await fetch(`${httpBase}/_officer/sessions/${sessionId}`, { method: 'DELETE' });
|
||||
expect(killed.status).toBe(200);
|
||||
await b.await((f) => f.type === 'exit', 'exit after kill');
|
||||
|
||||
const after = (await fetch(`${httpBase}/_officer/sessions`).then((r) => r.json())) as { sessions: any[] };
|
||||
expect(after.sessions.find((s) => s.sessionId === sessionId)).toBeUndefined();
|
||||
|
||||
b.close();
|
||||
officer.stop();
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import http from 'node:http';
|
||||
import { WebSocketServer } from 'ws';
|
||||
import * as store from './sessions.mjs';
|
||||
|
||||
// The sidecar's own listener — the thing that makes this a real sidecar rather than a set of commands the
|
||||
// platform drives. It binds an ephemeral loopback port and announces it; the platform authenticates the
|
||||
// browser and relays bytes here without reading them.
|
||||
//
|
||||
// The socket protocol is the one the browser already speaks, unchanged: {input,resize} in,
|
||||
// {output,replay,exit,panel-refresh} out. That is deliberate — the frontend did not have to move for the
|
||||
// transport to.
|
||||
|
||||
const wsSend = (socket) => ({
|
||||
send: (msg) => {
|
||||
if (socket.readyState === socket.OPEN) socket.send(JSON.stringify(msg));
|
||||
},
|
||||
});
|
||||
|
||||
const json = (res, status, body) => {
|
||||
const payload = JSON.stringify(body);
|
||||
res.writeHead(status, { 'content-type': 'application/json', 'content-length': Buffer.byteLength(payload) });
|
||||
res.end(payload);
|
||||
};
|
||||
|
||||
export function startServer() {
|
||||
const server = http.createServer((req, res) => {
|
||||
const url = new URL(req.url ?? '/', 'http://127.0.0.1');
|
||||
|
||||
// Officer-owned routes, reached through the platform's authenticated proxy.
|
||||
if (url.pathname === '/_officer/sessions' && req.method === 'GET') {
|
||||
return json(res, 200, { sessions: store.list() });
|
||||
}
|
||||
|
||||
const killMatch = url.pathname.match(/^\/_officer\/sessions\/([^/]+)$/);
|
||||
if (killMatch && req.method === 'DELETE') {
|
||||
const killed = store.kill(decodeURIComponent(killMatch[1]));
|
||||
return json(res, killed ? 200 : 404, { ok: killed });
|
||||
}
|
||||
|
||||
// The claude-done hook: tell attached terminals to refresh their panel.
|
||||
if (url.pathname === '/_officer/panel-refresh' && req.method === 'POST') {
|
||||
store.broadcastPanelRefresh();
|
||||
return json(res, 200, { ok: true });
|
||||
}
|
||||
|
||||
json(res, 404, { error: 'not found' });
|
||||
});
|
||||
|
||||
const wss = new WebSocketServer({ server, path: '/ws' });
|
||||
|
||||
wss.on('connection', (socket, req) => {
|
||||
const url = new URL(req.url ?? '/', 'http://127.0.0.1');
|
||||
const sessionId = url.searchParams.get('sessionId');
|
||||
if (!sessionId) {
|
||||
socket.close(1008, 'sessionId required');
|
||||
return;
|
||||
}
|
||||
|
||||
const client = wsSend(socket);
|
||||
const session = store.attach(sessionId, client, {
|
||||
cwd: url.searchParams.get('cwd') ?? undefined,
|
||||
cols: Number(url.searchParams.get('cols')) || 0,
|
||||
rows: Number(url.searchParams.get('rows')) || 0,
|
||||
});
|
||||
if (!session) {
|
||||
socket.close(1011, 'failed to start terminal');
|
||||
return;
|
||||
}
|
||||
|
||||
socket.on('message', (raw) => {
|
||||
let msg;
|
||||
try {
|
||||
msg = JSON.parse(typeof raw === 'string' ? raw : raw.toString());
|
||||
} catch {
|
||||
return; // malformed frame
|
||||
}
|
||||
if (msg.type === 'input') store.write(sessionId, msg.data);
|
||||
else if (msg.type === 'resize') store.resize(sessionId, msg.cols, msg.rows);
|
||||
});
|
||||
|
||||
// Detach, never kill: the shell outlives the viewer, which is what makes re-attach work at all.
|
||||
socket.on('close', () => store.detach(sessionId, client));
|
||||
socket.on('error', () => store.detach(sessionId, client));
|
||||
});
|
||||
|
||||
return new Promise((resolve) => {
|
||||
// Port 0 — the OS picks, and the platform learns it from the registration socket. Loopback only.
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const { port } = server.address();
|
||||
console.log(`[pty-sidecar] listening on http://127.0.0.1:${port}`);
|
||||
resolve(port);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { join } from 'node:path';
|
||||
import * as pty from 'node-pty';
|
||||
|
||||
// The shell store. Everything about running a terminal lives here: what shell, where it opens, how much
|
||||
// scrollback is kept, who is watching. The platform holds none of it — it authenticates a browser and
|
||||
// relays bytes to this process, and that is the whole of its involvement.
|
||||
|
||||
// What a re-attaching client gets back. 50KB was about one long agent turn, so reconnecting mid-task
|
||||
// showed you the tail and nothing else. Per session, so ten live shells is ~5MB — cheap next to node-pty.
|
||||
const BUFFER_MAX = 512 * 1024;
|
||||
|
||||
// HOME_DIR mirrors `data-path.ts:getOwnerHomeDir` — on a host where the owner's real login home differs
|
||||
// from this process's HOME, the shell should open in the former, like every other host-executing surface.
|
||||
const HOME_DIR = process.env.HOME_DIR ?? process.env.HOME ?? process.cwd();
|
||||
const SHELL = { command: process.env.SHELL ?? '/bin/zsh', args: ['-i'] };
|
||||
|
||||
// A terminal is always a plain host shell: the owner is the only account and it is their own machine
|
||||
// (`platform/CLAUDE.md` — do not add a jail without being asked).
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* term: import('node-pty').IPty, buffer: string, cols: number, rows: number,
|
||||
* createdAt: number, lastActivityAt: number, title: string, pid: number|undefined,
|
||||
* clients: Set<{ send: (msg: object) => void }>,
|
||||
* }} Session
|
||||
*/
|
||||
|
||||
/** @type {Map<string, Session>} */
|
||||
const sessions = new Map();
|
||||
|
||||
const resolveCwd = (cwd) => {
|
||||
if (!cwd || cwd === '~') return HOME_DIR;
|
||||
if (cwd.startsWith('~/')) return join(HOME_DIR, cwd.slice(2));
|
||||
if (cwd.startsWith('/')) return cwd;
|
||||
// Relative paths have no meaning here — this process's cwd is the repo, not the user's folder.
|
||||
return HOME_DIR;
|
||||
};
|
||||
|
||||
const appendBuffer = (session, data) => {
|
||||
session.buffer += data;
|
||||
if (session.buffer.length > BUFFER_MAX) {
|
||||
// Cut on a line boundary, not a byte offset. A blind slice can land inside an escape sequence, and the
|
||||
// replay then opens with the tail of a colour or cursor-move code — which xterm renders as garbage, or
|
||||
// worse, applies as a real instruction. Fall back to the raw cut when a single line is enormous.
|
||||
const cut = session.buffer.length - BUFFER_MAX;
|
||||
const nl = session.buffer.indexOf('\n', cut);
|
||||
session.buffer = nl !== -1 && nl - cut < 4096 ? session.buffer.slice(nl + 1) : session.buffer.slice(cut);
|
||||
}
|
||||
};
|
||||
|
||||
const broadcast = (session, msg) => {
|
||||
for (const client of session.clients) {
|
||||
try {
|
||||
client.send(msg);
|
||||
} catch {
|
||||
// client went away between the check and the write
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/** Attach a client to a session, spawning the shell if this is the first time we've seen the id. */
|
||||
export function attach(sessionId, client, { cwd, cols, rows } = {}) {
|
||||
let session = sessions.get(sessionId);
|
||||
|
||||
if (session) {
|
||||
session.clients.add(client);
|
||||
// Scrollback goes out as `replay`, not as ordinary output: the client may already be showing some of
|
||||
// it, so it resets and rebuilds from this rather than appending a second copy.
|
||||
if (session.buffer.length > 0) client.send({ type: 'replay', data: session.buffer });
|
||||
if (cols > 0 && rows > 0 && (cols !== session.cols || rows !== session.rows)) resize(sessionId, cols, rows);
|
||||
return session;
|
||||
}
|
||||
|
||||
const spawnCols = cols > 0 ? cols : 80;
|
||||
const spawnRows = rows > 0 ? rows : 24;
|
||||
|
||||
let term;
|
||||
try {
|
||||
term = pty.spawn(SHELL.command, SHELL.args, {
|
||||
name: 'xterm-256color',
|
||||
cols: spawnCols,
|
||||
rows: spawnRows,
|
||||
cwd: resolveCwd(cwd),
|
||||
// COLORTERM is how programs decide they may emit 24-bit colour — TERM only advertises 256.
|
||||
env: { ...process.env, TERM: 'xterm-256color', COLORTERM: 'truecolor' },
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to start terminal';
|
||||
client.send({ type: 'output', data: `\r\n[Terminal error] ${message}\r\n` });
|
||||
client.send({ type: 'exit', exitCode: 1 });
|
||||
return null;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
session = {
|
||||
term,
|
||||
buffer: '',
|
||||
cols: spawnCols,
|
||||
rows: spawnRows,
|
||||
createdAt: now,
|
||||
lastActivityAt: now,
|
||||
title: '',
|
||||
pid: term.pid,
|
||||
clients: new Set([client]),
|
||||
};
|
||||
sessions.set(sessionId, session);
|
||||
|
||||
term.onData((output) => {
|
||||
appendBuffer(session, output);
|
||||
session.lastActivityAt = Date.now();
|
||||
// The title the shell sets for itself (OSC 0/2 — usually the running command). It is what turns "some
|
||||
// uuid" into "the one running claude" in the session list.
|
||||
const titleMatch = /\x1b\][02];([^\x07\x1b]*)(?:\x07|\x1b\\)/.exec(output);
|
||||
if (titleMatch) session.title = titleMatch[1];
|
||||
broadcast(session, { type: 'output', data: output });
|
||||
});
|
||||
|
||||
term.onExit(({ exitCode, signal }) => {
|
||||
console.log(`[pty-sidecar] session ${sessionId} exited code=${exitCode} signal=${signal}`);
|
||||
broadcast(session, { type: 'exit', exitCode, signal });
|
||||
sessions.delete(sessionId);
|
||||
});
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
/** A client went away. The shell stays: it is re-attachable, and killing it is an explicit act. */
|
||||
export function detach(sessionId, client) {
|
||||
sessions.get(sessionId)?.clients.delete(client);
|
||||
}
|
||||
|
||||
export function write(sessionId, data) {
|
||||
sessions.get(sessionId)?.term.write(data ?? '');
|
||||
}
|
||||
|
||||
export function resize(sessionId, cols, rows) {
|
||||
const session = sessions.get(sessionId);
|
||||
if (!session || !(cols > 0) || !(rows > 0)) return;
|
||||
session.cols = cols;
|
||||
session.rows = rows;
|
||||
try {
|
||||
session.term.resize(cols, rows);
|
||||
} catch {
|
||||
// the shell died between the lookup and the resize
|
||||
}
|
||||
}
|
||||
|
||||
export function kill(sessionId) {
|
||||
const session = sessions.get(sessionId);
|
||||
if (!session) return false;
|
||||
try {
|
||||
session.term.kill();
|
||||
} catch {
|
||||
// already gone
|
||||
}
|
||||
sessions.delete(sessionId);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function list() {
|
||||
return [...sessions.entries()].map(([sessionId, s]) => ({
|
||||
sessionId,
|
||||
cols: s.cols,
|
||||
rows: s.rows,
|
||||
createdAt: s.createdAt,
|
||||
lastActivityAt: s.lastActivityAt,
|
||||
title: s.title || undefined,
|
||||
pid: s.pid,
|
||||
clients: s.clients.size,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Tell every attached client to refresh its panel — fired by the platform's claude-done hook. */
|
||||
export function broadcastPanelRefresh() {
|
||||
for (const session of sessions.values()) broadcast(session, { type: 'panel-refresh' });
|
||||
}
|
||||
|
||||
export function killAll() {
|
||||
for (const session of sessions.values()) {
|
||||
try {
|
||||
session.term.kill();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
sessions.clear();
|
||||
}
|
||||
Reference in New Issue
Block a user