sidecar self-registration: sidecars connect to API server instead of vice versa

Flips the connection model so sidecars register themselves with the API
server via WebSocket at /api/sidecar/register, enabling dynamic discovery,
location independence, and automatic reconnection from either side.

- Add registration protocol types and PTY command/event types
- Create sidecar-registry.ts (replaces sidecar-client.ts) as passive registry
- Create sidecar connector (connect.ts) with exponential backoff reconnect
- Convert process sidecar from WS server to WS client
- Convert PTY sidecar from WS server to multiplexed WS client
- Simplify terminal bridge to thin adapter using registry
- Add PTY sidecar as PM2-managed process
- Update all consumer imports

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 08:48:19 +00:00
co-authored by Claude Opus 4.6
parent 6cfa40bad1
commit a0d9ea63f9
17 changed files with 878 additions and 712 deletions
+144 -104
View File
@@ -1,13 +1,21 @@
// Ignore SIGINT — sudo/pty child processes may propagate it
process.on('SIGINT', () => {});
// Graceful shutdown
process.on('SIGINT', () => {
console.log('[pty-sidecar] shutting down...');
for (const [id, session] of sessions) {
try { session.term.kill(); } catch { /* ignore */ }
}
sessions.clear();
if (ws) { try { ws.close(); } catch { /* ignore */ } }
process.exit(0);
});
process.on('SIGTERM', () => process.emit('SIGINT'));
import http from 'node:http';
import { existsSync } from 'node:fs';
import { cp, mkdir } from 'node:fs/promises';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { execFile } from 'node:child_process';
import { WebSocketServer } from 'ws';
import WebSocket from 'ws';
import * as pty from 'node-pty';
const run = (cmd, args, opts = {}) =>
@@ -20,24 +28,24 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
const templateDir = join(__dirname, 'templates');
const port = Number(process.env.TERMINAL_PTY_PORT ?? '5337');
const host = process.env.TERMINAL_PTY_HOST ?? '127.0.0.1';
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`;
const BUFFER_MAX = 50 * 1024;
const RECONNECT_DELAYS = [200, 500, 1000, 2000, 4000, 8000, 15000];
/** @type {Map<string, { term: import('node-pty').IPty, buffer: string, cols: number, rows: number, ws: import('ws').WebSocket | null, initConfig: object }>} */
/** @type {Map<string, { term: import('node-pty').IPty, buffer: string, cols: number, rows: number, initConfig: object }>} */
const sessions = new Map();
const server = http.createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('terminal-sidecar');
});
const wss = new WebSocketServer({ server });
// ── Helpers ──
const sendJson = (ws, msg) => {
try {
ws.send(JSON.stringify(msg));
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(msg));
}
} catch {
// ignore
}
@@ -76,47 +84,26 @@ const appendBuffer = (session, data) => {
}
};
wss.on('connection', (ws) => {
let currentSessionId = null;
// ── Command handler ──
ws.on('message', async (data) => {
let msg;
try {
msg = JSON.parse(typeof data === 'string' ? data : data.toString());
} catch {
return;
}
if (msg.type === 'init') {
const sessionId = msg.sessionId;
async function handleCommand(ws, msg) {
switch (msg.type) {
case 'pty:init': {
const { sessionId, config } = msg;
if (!sessionId) return;
currentSessionId = sessionId;
const existing = sessions.get(sessionId);
console.log(`[sidecar] init sessionId=${sessionId} existing=${!!existing} total=${sessions.size}`);
console.log(`[pty-sidecar] init sessionId=${sessionId} existing=${!!existing} total=${sessions.size}`);
if (existing) {
// Evict old WS if still attached
if (existing.ws && existing.ws !== ws) {
sendJson(existing.ws, { type: 'detached' });
try {
existing.ws.close();
} catch {
// ignore
}
}
existing.ws = ws;
// Replay buffer
if (existing.buffer.length > 0) {
sendJson(ws, { type: 'output', data: existing.buffer });
sendJson(ws, { type: 'pty:output', sessionId, data: existing.buffer });
}
// Resize PTY to new client dimensions
const cols = msg.cols ?? existing.cols;
const rows = msg.rows ?? existing.rows;
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;
@@ -127,44 +114,40 @@ wss.on('connection', (ws) => {
}
}
sendJson(ws, { type: 'pty:ready', id: msg.id, sessionId });
return;
}
// New session — spawn PTY
const shell = msg.shell ?? { command: '/bin/bash', args: ['-i'] };
const cwd = msg.cwd ?? process.cwd();
const homeDir = msg.homeDir ?? process.cwd();
const userLabel = msg.userLabel ?? 'officer';
const username = msg.username ?? null;
const cols = msg.cols ?? 80;
const rows = msg.rows ?? 24;
const isHost = !!msg.host;
const shell = config.shell ?? { command: '/bin/bash', args: ['-i'] };
const cwd = config.cwd ?? process.cwd();
const homeDir = config.homeDir ?? process.cwd();
const userLabel = config.userLabel ?? 'officer';
const username = config.username ?? null;
const cols = config.cols ?? 80;
const rows = config.rows ?? 24;
const isHost = !!config.host;
let spawnCommand;
let spawnArgs;
let ptyEnv;
if (isHost) {
// Host session — spawn shell directly as current user
spawnCommand = shell.command;
spawnArgs = shell.args ?? [];
ptyEnv = { ...process.env, TERM: 'xterm-256color', ...(msg.env ?? {}) };
ptyEnv = { ...process.env, TERM: 'xterm-256color', ...(config.env ?? {}) };
} else if (username) {
// User session — spawn via sudo -u as the target Linux user
spawnCommand = 'sudo';
spawnArgs = ['-u', username, '-i', '/bin/zsh'];
ptyEnv = {
TERM: 'xterm-256color',
};
ptyEnv = { TERM: 'xterm-256color' };
} else {
// Fallback — direct spawn with custom env (legacy)
spawnCommand = shell.command;
spawnArgs = shell.args ?? [];
try {
await ensureUserFiles(homeDir);
} catch (err) {
console.error('[sidecar] ensureUserFiles failed:', err);
console.error('[pty-sidecar] ensureUserFiles failed:', err);
}
ptyEnv = {
@@ -180,8 +163,6 @@ wss.on('connection', (ws) => {
};
}
// For username sessions, don't set cwd — sudo -u -i will cd to the user's home.
// node-pty does chdir before exec, so it would fail if the service user can't access the dir.
const ptyCwd = username ? undefined : cwd;
let term;
@@ -195,8 +176,8 @@ wss.on('connection', (ws) => {
});
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to start terminal';
sendJson(ws, { type: 'output', data: `\r\n[Terminal error] ${message}\r\n` });
sendJson(ws, { type: 'exit' });
sendJson(ws, { type: 'pty:output', sessionId, data: `\r\n[Terminal error] ${message}\r\n` });
sendJson(ws, { type: 'pty:exit', sessionId, exitCode: 1 });
return;
}
@@ -205,66 +186,125 @@ wss.on('connection', (ws) => {
buffer: '',
cols,
rows,
ws,
initConfig: { shell, cwd, homeDir, userLabel },
};
sessions.set(sessionId, session);
term.onData((output) => {
appendBuffer(session, output);
if (session.ws) {
sendJson(session.ws, { type: 'output', data: output });
}
sendJson(ws, { type: 'pty:output', sessionId, data: output });
});
term.onExit(({ exitCode, signal }) => {
console.log(`[sidecar] session ${sessionId} exited code=${exitCode} signal=${signal}`);
if (session.ws) {
sendJson(session.ws, { type: 'exit' });
}
console.log(`[pty-sidecar] session ${sessionId} exited code=${exitCode} signal=${signal}`);
sendJson(ws, { type: 'pty:exit', sessionId, exitCode, signal });
sessions.delete(sessionId);
});
sendJson(ws, { type: 'pty:ready', id: msg.id, sessionId });
return;
}
// Route other messages to current session
if (!currentSessionId) return;
const session = sessions.get(currentSessionId);
if (!session) return;
switch (msg.type) {
case 'input':
case 'pty:input': {
const session = sessions.get(msg.sessionId);
if (session) {
session.term.write(msg.data ?? '');
break;
case 'resize':
if (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: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 'cwd':
if (msg.path) session.term.write(`cd ${JSON.stringify(msg.path)}\r`);
break;
}
break;
}
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;
let reconnectAttempt = 0;
let reconnectTimer = null;
console.log(`[pty-sidecar] starting, connecting to ${REGISTER_URL}`);
function connect() {
if (ws && (ws.readyState === WebSocket.CONNECTING || ws.readyState === WebSocket.OPEN)) return;
try {
ws = new WebSocket(REGISTER_URL);
} catch (err) {
console.error(`[pty-sidecar] failed to create WebSocket:`, err.message ?? err);
scheduleReconnect();
return;
}
ws.on('open', () => {
reconnectAttempt = 0;
console.log('[pty-sidecar] connected, sending registration...');
sendJson(ws, { type: 'register', name: 'pty', capabilities: ['terminal'] });
});
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;
}
handleCommand(ws, msg);
} catch {
// skip malformed messages
}
});
ws.on('close', () => {
// Detach WS from session — do NOT kill PTY
if (currentSessionId) {
const session = sessions.get(currentSessionId);
if (session && session.ws === ws) {
session.ws = null;
}
}
console.log('[pty-sidecar] disconnected from API server');
ws = null;
scheduleReconnect();
});
});
server.listen(port, host, () => {
console.log(`[terminal-sidecar] listening on ${host}:${port}`);
});
ws.on('error', (err) => {
if (reconnectAttempt <= 1) {
console.error(`[pty-sidecar] connection error: ${err.message ?? err}`);
}
// onclose will fire after this
});
}
function scheduleReconnect() {
if (reconnectTimer) return;
const delay = RECONNECT_DELAYS[Math.min(reconnectAttempt, RECONNECT_DELAYS.length - 1)];
reconnectAttempt++;
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
connect();
}, delay);
}
// Start connecting
connect();