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:
@@ -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();
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { ServerWebSocket } from 'bun';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { getHomeDir } from '@@/data-path';
|
||||
import { sendPtyCommand, sendPtyCommandAsync, on, isTerminalConnected } from '@@/sidecar-registry';
|
||||
import type { PtyInitConfig } from '../../sidecar/protocol';
|
||||
|
||||
type WSData = {
|
||||
userId: number;
|
||||
@@ -15,17 +16,20 @@ type WSData = {
|
||||
cols?: number;
|
||||
rows?: number;
|
||||
};
|
||||
|
||||
type BridgeSession = {
|
||||
client: ServerWebSocket<WSData>;
|
||||
sidecar: WebSocket | null;
|
||||
pendingMessages: string[];
|
||||
sessionId: string;
|
||||
unsubOutput: (() => void) | null;
|
||||
unsubExit: (() => void) | null;
|
||||
};
|
||||
|
||||
const HOST_SIDECAR_PORT = 5338;
|
||||
|
||||
const sessions = new Map<ServerWebSocket<WSData>, BridgeSession>();
|
||||
|
||||
let hostSidecarProcess: ReturnType<typeof import('bun').spawn> | null = null;
|
||||
let idCounter = 0;
|
||||
function nextId(): string {
|
||||
return `pty_${Date.now()}_${++idCounter}`;
|
||||
}
|
||||
|
||||
const sendOutput = (ws: ServerWebSocket<WSData>, data: string) => {
|
||||
try {
|
||||
@@ -35,105 +39,6 @@ const sendOutput = (ws: ServerWebSocket<WSData>, data: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
const connectSidecar = async (): Promise<WebSocket> => {
|
||||
const delays = [200, 300, 500, 800, 1200, 1600, 2000];
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (const delay of delays) {
|
||||
try {
|
||||
const ws = await new Promise<WebSocket>((resolve, reject) => {
|
||||
const socket = new WebSocket(`ws://127.0.0.1:${HOST_SIDECAR_PORT}`);
|
||||
const timeout = setTimeout(() => {
|
||||
try {
|
||||
socket.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
reject(new Error('Terminal sidecar timeout'));
|
||||
}, 2000);
|
||||
|
||||
socket.addEventListener('open', () => {
|
||||
clearTimeout(timeout);
|
||||
resolve(socket);
|
||||
});
|
||||
socket.addEventListener('error', () => {
|
||||
clearTimeout(timeout);
|
||||
reject(new Error('Terminal sidecar connection failed'));
|
||||
});
|
||||
});
|
||||
|
||||
return ws;
|
||||
} catch (err) {
|
||||
lastError = err instanceof Error ? err : new Error('Terminal sidecar connection failed');
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError ?? new Error('Terminal sidecar connection failed');
|
||||
};
|
||||
|
||||
const sidecarAlive = async (): Promise<boolean> => {
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:${HOST_SIDECAR_PORT}`, { signal: AbortSignal.timeout(500) });
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const killSidecarOnPort = (port: number) => {
|
||||
try {
|
||||
const result = Bun.spawnSync({ cmd: ['fuser', '-k', `${port}/tcp`], stdout: 'ignore', stderr: 'ignore' });
|
||||
if (result.exitCode === 0) console.log(`[terminal] killed stale sidecar on port ${port}`);
|
||||
} catch {
|
||||
// fuser not available or failed
|
||||
}
|
||||
};
|
||||
|
||||
const startHostSidecar = async () => {
|
||||
if (hostSidecarProcess) {
|
||||
hostSidecarProcess.kill();
|
||||
await hostSidecarProcess.exited.catch(() => {});
|
||||
hostSidecarProcess = null;
|
||||
}
|
||||
|
||||
killSidecarOnPort(HOST_SIDECAR_PORT);
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
const sidecarPath = fileURLToPath(new URL('./pty-sidecar.mjs', import.meta.url));
|
||||
hostSidecarProcess = Bun.spawn({
|
||||
cmd: ['node', sidecarPath],
|
||||
env: { ...process.env, TERMINAL_PTY_PORT: String(HOST_SIDECAR_PORT) },
|
||||
stdout: 'inherit',
|
||||
stderr: 'inherit',
|
||||
});
|
||||
console.log(`[terminal] host sidecar started on port ${HOST_SIDECAR_PORT}`);
|
||||
};
|
||||
|
||||
export const ensureHostSidecar = async () => {
|
||||
const alive = await sidecarAlive();
|
||||
if (!alive) {
|
||||
await startHostSidecar();
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
if (await sidecarAlive()) return;
|
||||
}
|
||||
throw new Error('Host sidecar failed to start');
|
||||
}
|
||||
};
|
||||
|
||||
export const initTerminalSidecars = async () => {
|
||||
// Sidecar is managed by pm2 — wait for it to be available
|
||||
for (let i = 0; i < 15; i++) {
|
||||
if (await sidecarAlive()) {
|
||||
console.log(`[terminal] host sidecar already running on port ${HOST_SIDECAR_PORT}`);
|
||||
return;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
console.warn(`[terminal] host sidecar not detected on port ${HOST_SIDECAR_PORT} — terminals will retry on connect`);
|
||||
};
|
||||
|
||||
const resolveCwd = (home: string, cwd?: string) => {
|
||||
if (!cwd || cwd === '~') return home;
|
||||
if (cwd.startsWith('~/')) return join(home, cwd.slice(2));
|
||||
@@ -150,102 +55,126 @@ export const terminalWebsocket = {
|
||||
`[terminal] open: email=${email} username=${username} role=${role} sandboxed=${sandboxed} isHost=${isHost}`,
|
||||
);
|
||||
|
||||
const session: BridgeSession = { client: ws, sidecar: null, pendingMessages: [] };
|
||||
sessions.set(ws, session);
|
||||
|
||||
let sidecar: WebSocket | null = null;
|
||||
try {
|
||||
sidecar = await connectSidecar();
|
||||
console.log('[terminal] sidecar connected');
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to connect terminal sidecar';
|
||||
console.error('[terminal] sidecar connection failed:', message);
|
||||
sendOutput(ws, `\r\n[Terminal error] ${message}\r\n`);
|
||||
sessions.delete(ws);
|
||||
if (!isTerminalConnected()) {
|
||||
sendOutput(ws, '\r\n[Terminal error] PTY sidecar is not connected\r\n');
|
||||
return;
|
||||
}
|
||||
|
||||
session.sidecar = sidecar;
|
||||
const sessionId = ws.data.sessionId ?? (isHost ? `host-${ws.data.userId}` : `default-${ws.data.userId}`);
|
||||
|
||||
sidecar.addEventListener('message', (ev) => {
|
||||
try {
|
||||
if (typeof ev.data === 'string') {
|
||||
ws.send(ev.data);
|
||||
} else {
|
||||
ws.send(new TextDecoder().decode(ev.data));
|
||||
}
|
||||
} catch {
|
||||
// ws already closed
|
||||
}
|
||||
});
|
||||
// Build PTY init config
|
||||
let config: PtyInitConfig;
|
||||
|
||||
if (isHost) {
|
||||
// Super Admin host terminal — spawn as the service user directly
|
||||
sidecar.send(
|
||||
JSON.stringify({
|
||||
type: 'init',
|
||||
host: true,
|
||||
sessionId: ws.data.sessionId ?? `host-${ws.data.userId}`,
|
||||
shell: { command: process.env.SHELL ?? '/bin/zsh', args: ['-i'] },
|
||||
cwd: resolveCwd(process.env.HOME!, ws.data.cwd),
|
||||
homeDir: process.env.HOME,
|
||||
userLabel: email,
|
||||
cols: ws.data.cols,
|
||||
rows: ws.data.rows,
|
||||
}),
|
||||
);
|
||||
config = {
|
||||
sessionId,
|
||||
host: true,
|
||||
shell: { command: process.env.SHELL ?? '/bin/zsh', args: ['-i'] },
|
||||
cwd: resolveCwd(process.env.HOME!, ws.data.cwd),
|
||||
homeDir: process.env.HOME!,
|
||||
userLabel: email,
|
||||
cols: ws.data.cols,
|
||||
rows: ws.data.rows,
|
||||
};
|
||||
} else {
|
||||
// User terminal — spawn as the target Linux user via sudo -u
|
||||
const homeDir = getHomeDir(email);
|
||||
mkdirSync(dirname(homeDir), { recursive: true });
|
||||
mkdirSync(homeDir, { recursive: true });
|
||||
|
||||
sidecar.send(
|
||||
JSON.stringify({
|
||||
type: 'init',
|
||||
username,
|
||||
sessionId: ws.data.sessionId ?? `default-${ws.data.userId}`,
|
||||
cwd: resolveCwd(homeDir, ws.data.cwd),
|
||||
homeDir,
|
||||
userLabel: email,
|
||||
cols: ws.data.cols,
|
||||
rows: ws.data.rows,
|
||||
}),
|
||||
);
|
||||
config = {
|
||||
sessionId,
|
||||
username,
|
||||
cwd: resolveCwd(homeDir, ws.data.cwd),
|
||||
homeDir,
|
||||
userLabel: email,
|
||||
cols: ws.data.cols,
|
||||
rows: ws.data.rows,
|
||||
};
|
||||
}
|
||||
|
||||
for (const msg of session.pendingMessages) sidecar.send(msg);
|
||||
session.pendingMessages = [];
|
||||
// Subscribe to events for this session
|
||||
const unsubOutput = on('pty:output', (msg) => {
|
||||
if (msg.type === 'pty:output' && msg.sessionId === sessionId) {
|
||||
try {
|
||||
ws.send(JSON.stringify({ type: 'output', data: msg.data }));
|
||||
} catch {
|
||||
// ws already closed
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const unsubExit = on('pty:exit', (msg) => {
|
||||
if (msg.type === 'pty:exit' && msg.sessionId === sessionId) {
|
||||
try {
|
||||
ws.send(JSON.stringify({ type: 'exit' }));
|
||||
} catch {
|
||||
// ws already closed
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const session: BridgeSession = { client: ws, sessionId, unsubOutput, unsubExit };
|
||||
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`);
|
||||
unsubOutput();
|
||||
unsubExit();
|
||||
sessions.delete(ws);
|
||||
}
|
||||
},
|
||||
|
||||
message(ws: ServerWebSocket<WSData>, raw: string | Buffer) {
|
||||
const session = sessions.get(ws);
|
||||
if (!session) return;
|
||||
|
||||
const payload = typeof raw === 'string' ? raw : raw.toString();
|
||||
|
||||
if (!session.sidecar || session.sidecar.readyState !== WebSocket.OPEN) {
|
||||
session.pendingMessages.push(payload);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
session.sidecar.send(payload);
|
||||
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;
|
||||
case 'cwd':
|
||||
if (msg.path) {
|
||||
sendPtyCommand({
|
||||
type: 'pty:input',
|
||||
id: nextId(),
|
||||
sessionId: session.sessionId,
|
||||
data: `cd ${JSON.stringify(msg.path)}\r`,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
// ignore malformed messages
|
||||
}
|
||||
},
|
||||
|
||||
close(ws: ServerWebSocket<WSData>) {
|
||||
const session = sessions.get(ws);
|
||||
if (session?.sidecar) {
|
||||
try {
|
||||
session.sidecar.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
if (session) {
|
||||
session.unsubOutput?.();
|
||||
session.unsubExit?.();
|
||||
// Don't kill PTY — it can be reattached
|
||||
sessions.delete(ws);
|
||||
}
|
||||
sessions.delete(ws);
|
||||
},
|
||||
|
||||
drain() {},
|
||||
@@ -253,8 +182,8 @@ export const terminalWebsocket = {
|
||||
|
||||
export const broadcastPanelRefresh = (email: string) => {
|
||||
const msg = JSON.stringify({ type: 'panel-refresh' });
|
||||
for (const [ws, session] of sessions) {
|
||||
if (ws.data.email === email && session.sidecar) {
|
||||
for (const [ws] of sessions) {
|
||||
if (ws.data.email === email) {
|
||||
try {
|
||||
ws.send(msg);
|
||||
} catch {
|
||||
@@ -263,12 +192,3 @@ export const broadcastPanelRefresh = (email: string) => {
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const stopAllSidecars = async () => {
|
||||
if (hostSidecarProcess) {
|
||||
hostSidecarProcess.kill();
|
||||
await hostSidecarProcess.exited.catch(() => {});
|
||||
hostSidecarProcess = null;
|
||||
console.log('[terminal] host sidecar stopped');
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user