Files
platform/src/servers/api/terminal/pty-sidecar.mjs
T
pastilhasandClaude Opus 4.8 1824f53c89 let the pty sidecar decide what shell it runs
officer built the whole PtyInitConfig: it read the owner's SHELL (defaulting to
/bin/zsh), added `-i`, read their HOME, expanded `~` against it, and hardcoded
`host: true`. none of that is a proxy's business — the sidecar is the process that
calls pty.spawn, so it is the one that should know what to spawn and where.

the config now carries only what the bridge actually knows: sessionId, the folder
the panel was opened on, and the client's cols/rows. shell, args, home and cwd
resolution moved into the sidecar. home comes from HOME_DIR ?? HOME, mirroring
data-path.ts:getOwnerHomeDir — terminal was the one host-executing surface reading
process.env.HOME directly, which is identical here and divergent anywhere HOME_DIR
is set to something else.

deleted the bwrap sandbox branch rather than moving it. it was selected by
`config.host`, which officer hardcoded to true, so it never ran — and it expected
`shell` to contain a fully-built bwrap command that nothing on either side ever
built. it could not have worked. a terminal here is the owner's own shell on the
owner's own machine by design (platform/CLAUDE.md), so there is no jail to preserve.
its ensureUserFiles half duplicated api/users/provision.ts:seedShellConfigs, which
is the live seeder of those same templates and stays.

also deleted the 'cwd' handler that turned a message into `cd <path>\r` typed at
the shell. no frontend has ever sent that message — the browser composes its own cd
— so it was unreachable, and synthesizing keystrokes is not something a relay
should do.

the integration test pins SHELL and HOME_DIR now that the sidecar reads them, and
asserts the shell starts in the resolved `~` rather than officer having resolved it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 05:02:10 +00:00

258 lines
8.3 KiB
JavaScript

// 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<string, { term: import('node-pty').IPty, buffer: string, cols: number, rows: number }>} */
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();