// 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 { join } from 'node:path'; import WebSocket from 'ws'; import * as pty from 'node-pty'; 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]; // ── What kind of shell this process runs ── // // 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. 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; }; /** @type {Map} */ 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)); } } catch { // ignore } }; 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(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, env: { ...process.env, TERM: 'xterm-256color' }, }); } 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 session = { term, buffer: '', cols, rows }; sessions.set(sessionId, session); term.onData((output) => { appendBuffer(session, output); 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: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({ 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(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();