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} 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. // // `osUser` scopes both to one account's sessions. The platform sends it for a member and omits it for the // owner; absent means unscoped. Until this existed these two listed and killed EVERY shell on the box for // anyone who could reach them, which was safe only because the terminal was owner-only. const scope = url.searchParams.has('osUser') ? (url.searchParams.get('osUser') || null) : undefined; if (url.pathname === '/_officer/sessions' && req.method === 'GET') { return json(res, 200, { sessions: store.list(scope) }); } const killMatch = url.pathname.match(/^\/_officer\/sessions\/([^/]+)$/); if (killMatch && req.method === 'DELETE') { const killed = store.kill(decodeURIComponent(killMatch[1]), scope); return json(res, killed ? 200 : 404, { ok: killed }); } 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, // Injected by the platform's bridge from the authenticated account, after stripping whatever the // browser sent. Absent for the owner, whose shell runs as this process. osUser: url.searchParams.get('osUser') || undefined, home: url.searchParams.get('home') || undefined, }); 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); }); }); }