Merge branch 'sidecars-pty' into sidecars

This commit is contained in:
2026-07-30 05:43:01 +00:00
11 changed files with 234 additions and 158 deletions
-297
View File
@@ -1,297 +0,0 @@
// 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 { 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 WebSocket from 'ws';
import * as pty from 'node-pty';
const run = (cmd, args, opts = {}) =>
new Promise((resolve) => {
const proc = execFile(cmd, args, { stdio: 'ignore', ...opts }, () => resolve());
proc.on('error', () => resolve());
});
const __dirname = dirname(fileURLToPath(import.meta.url));
const templateDir = join(__dirname, 'templates');
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, initConfig: object }>} */
const sessions = new Map();
// ── Helpers ──
const sendJson = (ws, msg) => {
try {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(msg));
}
} catch {
// ignore
}
};
const ensureUserFiles = async (homeDir) => {
await mkdir(homeDir, { recursive: true });
await mkdir(join(homeDir, '.config'), { recursive: true });
await mkdir(join(homeDir, '.local', 'bin'), { recursive: true });
const zshrcPath = join(homeDir, '.zshrc');
if (!existsSync(zshrcPath)) {
await cp(join(templateDir, '.zshrc'), zshrcPath);
}
const tmuxconfPath = join(homeDir, '.tmux.conf');
if (!existsSync(tmuxconfPath)) {
await cp(join(templateDir, '.tmux.conf'), tmuxconfPath);
}
const starshipPath = join(homeDir, '.config', 'starship-officer.toml');
if (!existsSync(starshipPath)) {
await cp(join(templateDir, 'starship-officer.toml'), starshipPath);
}
const ohMyZshPath = join(homeDir, '.oh-my-zsh');
if (!existsSync(ohMyZshPath)) {
await run('git', ['clone', '--depth=1', 'https://github.com/ohmyzsh/ohmyzsh.git', ohMyZshPath]);
}
};
const appendBuffer = (session, data) => {
session.buffer += data;
if (session.buffer.length > BUFFER_MAX) {
session.buffer = session.buffer.slice(-BUFFER_MAX);
}
};
// ── Command handler ──
async function handleCommand(ws, 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) {
// Replay buffer
if (existing.buffer.length > 0) {
sendJson(ws, { type: 'pty:output', 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(ws, { type: 'pty:ready', id: msg.id, sessionId });
return;
}
// New session — spawn PTY
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 cols = config.cols ?? 80;
const rows = config.rows ?? 24;
const isHost = !!config.host;
let spawnCommand;
let spawnArgs;
let ptyEnv;
if (isHost) {
spawnCommand = shell.command;
spawnArgs = shell.args ?? [];
ptyEnv = { ...process.env, TERM: 'xterm-256color', ...(config.env ?? {}) };
} else {
// Sandboxed mode: shell config contains the full bwrap command
spawnCommand = shell.command;
spawnArgs = shell.args ?? [];
try {
await ensureUserFiles(homeDir);
} catch (err) {
console.error('[pty-sidecar] ensureUserFiles failed:', err);
}
// bwrap sets env vars internally via --setenv, so use minimal host env
ptyEnv = { TERM: 'xterm-256color' };
}
const ptyCwd = isHost ? cwd : undefined;
let term;
try {
term = pty.spawn(spawnCommand, spawnArgs, {
name: 'xterm-256color',
cols,
rows,
cwd: ptyCwd,
env: ptyEnv,
});
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to start terminal';
sendJson(ws, { type: 'pty:output', sessionId, data: `\r\n[Terminal error] ${message}\r\n` });
sendJson(ws, { type: 'pty:exit', sessionId, exitCode: 1 });
return;
}
const session = {
term,
buffer: '',
cols,
rows,
initConfig: { shell, cwd, homeDir, userLabel },
};
sessions.set(sessionId, session);
term.onData((output) => {
appendBuffer(session, output);
sendJson(ws, { type: 'pty:output', sessionId, data: output });
});
term.onExit(({ exitCode, signal }) => {
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;
}
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: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', () => {
console.log('[pty-sidecar] disconnected from API server');
ws = null;
scheduleReconnect();
});
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();
+25 -56
View File
@@ -1,5 +1,4 @@
import type { ServerWebSocket } from 'bun';
import { join } from 'node:path';
import { sendPtyCommand, sendPtyCommandAsync, on, isTerminalConnected } from '@@/sidecar-registry';
import type { PtyInitConfig } from '../../sidecar/protocol';
@@ -16,8 +15,7 @@ type WSData = {
type BridgeSession = {
client: ServerWebSocket<WSData>;
sessionId: string;
unsubOutput: (() => void) | null;
unsubExit: (() => void) | null;
unsubs: Array<() => void>;
};
const sessions = new Map<ServerWebSocket<WSData>, BridgeSession>();
@@ -35,13 +33,6 @@ const sendOutput = (ws: ServerWebSocket<WSData>, data: string) => {
}
};
const resolveCwd = (home: string, cwd?: string) => {
if (!cwd || cwd === '~') return home;
if (cwd.startsWith('~/')) return join(home, cwd.slice(2));
if (cwd.startsWith('/')) return cwd;
return home;
};
export const terminalWebsocket = {
async open(ws: ServerWebSocket<WSData>) {
const { email, username } = ws.data;
@@ -55,40 +46,27 @@ export const terminalWebsocket = {
const sessionId = ws.data.sessionId ?? `host-${ws.data.userId}`;
// The server owner is the only account, so the terminal is always a plain host shell.
const config: PtyInitConfig = {
// 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,
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,
unsubs: [relay('pty:output', 'output'), relay('pty:replay', 'replay'), relay('pty:exit', 'exit')],
};
// 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
@@ -97,8 +75,7 @@ export const terminalWebsocket = {
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to initialize terminal';
sendOutput(ws, `\r\n[Terminal error] ${message}\r\n`);
unsubOutput();
unsubExit();
for (const unsub of session.unsubs) unsub();
sessions.delete(ws);
}
},
@@ -126,16 +103,9 @@ export const terminalWebsocket = {
});
}
break;
case 'cwd':
if (msg.path) {
sendPtyCommand({
type: 'pty:input',
id: nextId(),
sessionId: session.sessionId,
data: `cd ${JSON.stringify(msg.path)}\r`,
});
}
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.
}
} catch {
// ignore malformed messages
@@ -145,8 +115,7 @@ export const terminalWebsocket = {
close(ws: ServerWebSocket<WSData>) {
const session = sessions.get(ws);
if (session) {
session.unsubOutput?.();
session.unsubExit?.();
for (const unsub of session.unsubs) unsub();
// Don't kill PTY — it can be reattached
sessions.delete(ws);
}
+1 -1
View File
@@ -3,7 +3,7 @@ import { join } from 'node:path';
import { DATA_PATH, getHomeDir, toShellUsername } from '@@/data-path';
import { generateContainerContext, generateClaudeSettings } from '@@/generate-container-context';
const TEMPLATE_DIR = join(import.meta.dir, '../terminal/templates');
const TEMPLATE_DIR = join(import.meta.dir, 'templates');
const copyTemplate = async (src: string, dest: string) => {
if (existsSync(dest)) return;