The PTY runtime exported an empty NODE_CHANNEL_FD to override the daemon's IPC descriptor, because bun-pty merges the native environ back into the child and a JS-only delete does not stick. Node CLIs launched from the shell (opencode, claude) then printed "warn: Failed to parse IPC channel number ''" on exit. The Linux-only env -u ARGV0 wrapper now applies on macOS and Linux and also unsets NODE_CHANNEL_FD, so the variable is gone instead of empty. Testing: runtime and inherited-env tests updated for the POSIX wrapper; verified in the running app that exiting opencode no longer prints the warning.
579 lines
32 KiB
JavaScript
579 lines
32 KiB
JavaScript
import { randomUUID } from 'node:crypto';
|
|
import { WebSocketServer } from 'ws';
|
|
import {
|
|
TERMINAL_WS_MAX_PAYLOAD_BYTES,
|
|
TERMINAL_WS_PATH,
|
|
createTerminalWsControlFrame,
|
|
parseRequestPathname,
|
|
readTerminalWsControlFrame,
|
|
} from './terminal-ws-protocol.js';
|
|
import { sanitizeTerminalHistoryChunk } from './history.js';
|
|
import { consumeTerminalThemeQueries, terminalThemeModeReport } from './theme-response.js';
|
|
import { buildTerminalShellLaunch, createTerminalShellResolver, normalizeTerminalShell } from './shells.js';
|
|
import { stripAppImageArgv0Leak, resolvePosixPtyLaunch } from '../inherited-env.js';
|
|
|
|
const MAX_SESSIONS = 20;
|
|
const MAX_HISTORY_BYTES = 512 * 1024;
|
|
const MAX_INPUT_CHARS = 65_536;
|
|
const IDLE_TIMEOUT_MS = 30 * 60 * 1000;
|
|
const TERMINATION_GRACE_MS = 1000;
|
|
const INTERACTIVE_TERMINAL_MODE = 'interactive';
|
|
const COMMAND_TERMINAL_MODE = 'command';
|
|
// Error code the create/restart routes attach when the requested cwd no longer
|
|
// exists. Mirrored by `TERMINAL_CWD_MISSING_CODE` in packages/ui/src/lib/terminalApi.ts.
|
|
const TERMINAL_CWD_MISSING_CODE = 'TERMINAL_CWD_MISSING';
|
|
const TERMINAL_PURPOSE = Object.freeze({ type: 'terminal' });
|
|
const MAX_PURPOSE_ID_CHARS = 128;
|
|
const OBJECT_TAG = '[object Object]';
|
|
const validateSize = (value, max) => Number.isInteger(value) && value >= 1 && value <= max;
|
|
const isString = (value) => String(value) === value;
|
|
const isObjectRecord = (value) => value != null && !Array.isArray(value) && Object.prototype.toString.call(value) === OBJECT_TAG;
|
|
const normalizeCreateMode = ({ mode, command }) => {
|
|
const normalizedMode = mode == null ? INTERACTIVE_TERMINAL_MODE : mode;
|
|
if (normalizedMode !== INTERACTIVE_TERMINAL_MODE && normalizedMode !== COMMAND_TERMINAL_MODE) throw new Error('Invalid terminal mode');
|
|
if (normalizedMode === INTERACTIVE_TERMINAL_MODE) {
|
|
if (command != null) throw new Error('Interactive terminal create does not accept a command');
|
|
return { mode: INTERACTIVE_TERMINAL_MODE, command: null };
|
|
}
|
|
if (!isString(command) || !command.trim()) throw new Error('Terminal command is required');
|
|
const trimmedCommand = command.trim();
|
|
if (trimmedCommand.length > MAX_INPUT_CHARS) throw new Error('Terminal command exceeds the input limit');
|
|
return { mode: COMMAND_TERMINAL_MODE, command: trimmedCommand };
|
|
};
|
|
const normalizePurposeId = (value, errorMessage) => {
|
|
if (!isString(value) || !value.trim()) throw new Error(errorMessage);
|
|
const normalized = value.trim();
|
|
if (normalized.length > MAX_PURPOSE_ID_CHARS) throw new Error(errorMessage);
|
|
return normalized;
|
|
};
|
|
const normalizeTerminalPurpose = (value) => {
|
|
if (value == null) return TERMINAL_PURPOSE;
|
|
if (!isObjectRecord(value)) throw new Error('Invalid terminal purpose');
|
|
if (value.type === 'terminal') return TERMINAL_PURPOSE;
|
|
if (value.type !== 'project-action') throw new Error('Invalid terminal purpose');
|
|
return {
|
|
type: 'project-action',
|
|
actionId: normalizePurposeId(value.actionId, 'Terminal project action id is required'),
|
|
executionId: normalizePurposeId(value.executionId, 'Terminal execution id is required'),
|
|
};
|
|
};
|
|
const getSessionPurpose = (session) => session.purpose ?? TERMINAL_PURPOSE;
|
|
const isPurposeActionMatch = (left, right) => {
|
|
if (left.type !== right.type) return false;
|
|
return left.type !== 'project-action' || left.actionId === right.actionId;
|
|
};
|
|
const findRunningActionSession = (sessions, resolvedCwd, purpose, path) => {
|
|
if (purpose.type !== 'project-action') return null;
|
|
for (const session of sessions.values()) {
|
|
if (session.status !== 'running') continue;
|
|
if (path.resolve(session.cwd) !== resolvedCwd) continue;
|
|
const sessionPurpose = getSessionPurpose(session);
|
|
if (sessionPurpose.type === 'project-action' && sessionPurpose.actionId === purpose.actionId) return session;
|
|
}
|
|
return null;
|
|
};
|
|
const findPendingActionCreate = (pendingSessionCreates, resolvedCwd, purpose) => {
|
|
if (purpose.type !== 'project-action') return null;
|
|
for (const pending of pendingSessionCreates.values()) {
|
|
if (pending.cancelled || pending.cwd !== resolvedCwd) continue;
|
|
if (pending.purpose?.type === 'project-action' && pending.purpose.actionId === purpose.actionId) return pending;
|
|
}
|
|
return null;
|
|
};
|
|
const trimHistory = (history) => {
|
|
const bytes = Buffer.from(history);
|
|
if (bytes.byteLength <= MAX_HISTORY_BYTES) return history;
|
|
let start = bytes.byteLength - MAX_HISTORY_BYTES;
|
|
while (start < bytes.byteLength && (bytes[start] & 0xc0) === 0x80) start += 1;
|
|
return bytes.subarray(start).toString('utf8');
|
|
};
|
|
|
|
export function createTerminalRuntime({
|
|
app, server, fs, path, uiAuthController, buildAugmentedPath, searchPathFor, isExecutable,
|
|
isRequestOriginAllowed, rejectWebSocketUpgrade, TERMINAL_INPUT_WS_HEARTBEAT_INTERVAL_MS,
|
|
loadPtyProvider, terminalTerminationGraceMs = TERMINATION_GRACE_MS,
|
|
}) {
|
|
const sessions = new Map();
|
|
const pendingSessionCreates = new Map();
|
|
const pendingSessionRestarts = new Map();
|
|
const connections = new Set();
|
|
const pendingTerminations = new Set();
|
|
const runtime = typeof globalThis.Bun === 'undefined' ? 'node' : 'bun';
|
|
let ptyProviderPromise = null;
|
|
let wsServer = new WebSocketServer({ noServer: true, maxPayload: TERMINAL_WS_MAX_PAYLOAD_BYTES });
|
|
const shellResolver = createTerminalShellResolver({ fs, path, searchPathFor, isExecutable, buildAugmentedPath });
|
|
|
|
const getPtyProvider = async () => {
|
|
if (!ptyProviderPromise) {
|
|
ptyProviderPromise = loadPtyProvider ? loadPtyProvider() : (async () => {
|
|
if (typeof globalThis.Bun !== 'undefined') {
|
|
try { const pty = await import('bun-pty'); return { spawn: pty.spawn, backend: 'bun-pty' }; } catch { /* fall through */ }
|
|
}
|
|
const pty = await import('node-pty');
|
|
return { spawn: pty.spawn, backend: 'node-pty' };
|
|
})();
|
|
}
|
|
return ptyProviderPromise;
|
|
};
|
|
|
|
const spawnPty = async ({ cwd, cols, rows, themeMode, shell, loginShell, mode, command }) => {
|
|
const provider = await getPtyProvider();
|
|
const resolvedShell = await shellResolver.resolve(shell);
|
|
let lastError = null;
|
|
for (const executable of resolvedShell.executables) {
|
|
try {
|
|
const env = { ...process.env, PATH: buildAugmentedPath(), TERM: 'xterm-256color', COLORTERM: 'truecolor', COLORFGBG: themeMode === 'light' ? '0;15' : '15;0' };
|
|
// The daemon's IPC fd is closed inside the PTY; an inherited NODE_CHANNEL_FD
|
|
// (even an empty one) makes Node CLIs warn about an unparsable IPC channel.
|
|
delete env.NODE_CHANNEL_FD;
|
|
delete env.BASH_XTRACEFD; delete env.BASH_ENV; delete env.ENV; delete env.ELECTRON_RUN_AS_NODE;
|
|
// AppImage exports ARGV0; zsh would otherwise rewrite argv[0] for every command (#2588).
|
|
stripAppImageArgv0Leak(env);
|
|
const shellLaunch = buildTerminalShellLaunch(executable, { mode, command, loginShell });
|
|
// bun-pty merges the native OS environ back in, so the POSIX launch is
|
|
// wrapped with `env -u` for the variables deleted above.
|
|
const launch = resolvePosixPtyLaunch(shellLaunch.executable, shellLaunch.args);
|
|
const options = { name: 'xterm-256color', cwd, cols, rows, env };
|
|
if (process.platform === 'win32') options.useConpty = true;
|
|
return { process: await provider.spawn(launch.executable, launch.args, options), backend: provider.backend, shell: resolvedShell.id, loginShell };
|
|
} catch (error) { lastError = error; }
|
|
}
|
|
throw lastError ?? new Error('No executable shell found');
|
|
};
|
|
|
|
const killProcess = (ptyProcess, force = false) => {
|
|
if (!ptyProcess) return;
|
|
if (process.platform !== 'win32' && Number.isInteger(ptyProcess.pid) && ptyProcess.pid > 0) {
|
|
try { process.kill(-ptyProcess.pid, force ? 'SIGKILL' : 'SIGTERM'); } catch { /* already gone */ }
|
|
}
|
|
try { ptyProcess.kill(force ? 'SIGKILL' : undefined); } catch { /* already gone */ }
|
|
};
|
|
|
|
const terminateProcess = (ptyProcess, force = false) => {
|
|
if (!ptyProcess) return Promise.resolve();
|
|
if (force) { killProcess(ptyProcess, true); return Promise.resolve(); }
|
|
let termination;
|
|
termination = new Promise((resolve) => {
|
|
let settled = false;
|
|
let disposable = null;
|
|
const finish = () => {
|
|
if (settled) return;
|
|
settled = true;
|
|
clearTimeout(timeout);
|
|
disposable?.dispose?.();
|
|
resolve();
|
|
};
|
|
const timeout = setTimeout(() => { killProcess(ptyProcess, true); finish(); }, terminalTerminationGraceMs);
|
|
try { disposable = ptyProcess.onExit(() => finish()); } catch { /* backend is already gone */ }
|
|
killProcess(ptyProcess, false);
|
|
}).finally(() => pendingTerminations.delete(termination));
|
|
pendingTerminations.add(termination);
|
|
return termination;
|
|
};
|
|
|
|
const send = (socket, message) => {
|
|
if (socket?.readyState !== 1) return false;
|
|
try { socket.send(createTerminalWsControlFrame(message), { binary: true }); return true; } catch { return false; }
|
|
};
|
|
|
|
const closeAttachments = (sessionId, code, message) => {
|
|
for (const connection of connections) {
|
|
if (!connection.attachments.delete(sessionId)) continue;
|
|
send(connection.socket, { t: 'error', v: 3, s: sessionId, code, message, fatal: true });
|
|
}
|
|
};
|
|
|
|
const snapshot = (session) => ({
|
|
t: 'snapshot', v: 3, s: session.id, q: session.sequence, history: session.history,
|
|
// The PTY size the history was drawn for: a client replays history at this
|
|
// size before fitting its own viewport, so shell output wrapped for one
|
|
// width never gets re-laid-out at another.
|
|
cols: session.cols, rows: session.rows,
|
|
status: session.status, exitCode: session.exitCode, signal: session.signal,
|
|
mode: session.mode ?? INTERACTIVE_TERMINAL_MODE, purpose: getSessionPurpose(session),
|
|
runtime, ptyBackend: session.backend,
|
|
});
|
|
|
|
const publish = (session, event) => {
|
|
session.sequence += 1;
|
|
const message = { ...event, v: 3, s: session.id, q: session.sequence };
|
|
for (const connection of connections) {
|
|
const attachment = connection.attachments.get(session.id);
|
|
if (!attachment) continue;
|
|
if (attachment.initializing) attachment.pending.push(message);
|
|
else send(connection.socket, message);
|
|
}
|
|
};
|
|
|
|
const drainEvents = (session) => {
|
|
if (session.draining) return;
|
|
session.draining = true;
|
|
try {
|
|
while (session.eventQueue.length > 0) {
|
|
const event = session.eventQueue.shift();
|
|
if (event.process !== session.process) continue;
|
|
if (event.type === 'output') {
|
|
const theme = consumeTerminalThemeQueries(session.pendingThemeControlSequence, event.data, {
|
|
themeMode: session.themeMode,
|
|
background: session.terminalBackground,
|
|
foreground: session.terminalForeground,
|
|
modeEnabled: session.themeModeEnabled,
|
|
}, { respondToPrimaryDeviceAttributes: true });
|
|
session.pendingThemeControlSequence = theme.pending;
|
|
session.themeModeEnabled = theme.modeEnabled;
|
|
for (const response of theme.responses) session.process?.write(response);
|
|
const sanitized = sanitizeTerminalHistoryChunk(session.pendingHistoryControlSequence, event.data);
|
|
session.pendingHistoryControlSequence = sanitized.pending;
|
|
session.history = trimHistory(session.history + sanitized.visible);
|
|
session.lastActivity = Date.now();
|
|
publish(session, { t: 'output', d: event.data, ...(sanitized.visible !== event.data ? { r: sanitized.visible } : {}) });
|
|
} else {
|
|
session.status = 'exited';
|
|
session.exitCode = Number.isInteger(event.exitCode) ? event.exitCode : null;
|
|
session.signal = Number.isInteger(event.signal) ? event.signal : null;
|
|
session.process = null;
|
|
publish(session, { t: 'exit', exitCode: session.exitCode, signal: session.signal });
|
|
}
|
|
}
|
|
} finally { session.draining = false; }
|
|
};
|
|
|
|
const wire = (session, ptyProcess) => {
|
|
ptyProcess.onData((data) => { session.eventQueue.push({ type: 'output', process: ptyProcess, data }); drainEvents(session); });
|
|
ptyProcess.onExit(({ exitCode, signal }) => { session.eventQueue.push({ type: 'exit', process: ptyProcess, exitCode, signal }); drainEvents(session); });
|
|
};
|
|
|
|
// A working directory that no longer exists (a deleted worktree) is the one
|
|
// rejection the client can recover from by moving the session to its
|
|
// project, so the response names it. Every other rejection stays generic.
|
|
const invalidWorkingDirectory = (code) => Object.assign(new Error('Invalid working directory'), code ? { code } : {});
|
|
const validateCwd = async (cwd) => {
|
|
if (typeof cwd !== 'string' || !cwd.trim()) throw new Error('cwd is required');
|
|
let stats;
|
|
try { stats = await fs.promises.stat(cwd); }
|
|
catch (error) { throw invalidWorkingDirectory(error?.code === 'ENOENT' || error?.code === 'ENOTDIR' ? TERMINAL_CWD_MISSING_CODE : undefined); }
|
|
if (!stats?.isDirectory()) throw invalidWorkingDirectory();
|
|
};
|
|
const errorBody = (error, fallback) => ({ error: error?.message || fallback, ...(typeof error?.code === 'string' ? { code: error.code } : {}) });
|
|
|
|
const applyAppearance = (session, { themeMode, terminalBackground, terminalForeground }) => {
|
|
const previous = [session.themeMode, session.terminalBackground, session.terminalForeground];
|
|
if (themeMode === 'light' || themeMode === 'dark') session.themeMode = themeMode;
|
|
if (typeof terminalBackground === 'string') session.terminalBackground = terminalBackground;
|
|
if (typeof terminalForeground === 'string') session.terminalForeground = terminalForeground;
|
|
const changed = previous[0] !== session.themeMode || previous[1] !== session.terminalBackground || previous[2] !== session.terminalForeground;
|
|
if (changed && session.themeModeEnabled) {
|
|
try { session.process?.write(terminalThemeModeReport(session.themeMode)); } catch { /* process exited */ }
|
|
}
|
|
};
|
|
|
|
const startSession = async (session, { cwd, cols, rows, themeMode = 'dark', terminalBackground, terminalForeground, shell, loginShell, mode = INTERACTIVE_TERMINAL_MODE, command = null, purpose = TERMINAL_PURPOSE }, clear = true) => {
|
|
await validateCwd(cwd);
|
|
const spawned = await spawnPty({ cwd, cols, rows, themeMode, shell, loginShell, mode, command });
|
|
if (clear) { session.history = ''; session.pendingHistoryControlSequence = ''; session.pendingThemeControlSequence = ''; session.themeModeEnabled = false; }
|
|
session.cwd = cwd; session.cols = cols; session.rows = rows; session.process = spawned.process;
|
|
session.backend = spawned.backend; session.shell = spawned.shell; session.loginShell = spawned.loginShell; session.status = 'running'; session.exitCode = null; session.signal = null;
|
|
session.mode = mode; session.command = mode === COMMAND_TERMINAL_MODE ? command : null;
|
|
session.purpose = purpose;
|
|
session.themeMode = themeMode === 'light' ? 'light' : 'dark'; session.terminalBackground = terminalBackground; session.terminalForeground = terminalForeground;
|
|
session.lastActivity = Date.now(); session.eventQueue.length = 0;
|
|
wire(session, spawned.process);
|
|
return spawned.process;
|
|
};
|
|
|
|
const createSession = async ({ sessionId, cwd, cols = 80, rows = 24, themeMode, terminalBackground, terminalForeground, shell = 'auto', loginShell = false, mode, command, purpose }) => {
|
|
if (!validateSize(cols, 1000) || !validateSize(rows, 500)) throw new Error('Invalid terminal dimensions');
|
|
if (typeof loginShell !== 'boolean') throw new Error('Invalid terminal login mode');
|
|
const normalizedShell = normalizeTerminalShell(shell);
|
|
if (!normalizedShell) throw new Error('Invalid terminal shell');
|
|
const launchMode = normalizeCreateMode({ mode, command });
|
|
const normalizedPurpose = normalizeTerminalPurpose(purpose);
|
|
const id = typeof sessionId === 'string' && sessionId.trim() ? sessionId.trim() : randomUUID();
|
|
if (id.length > 128) throw new Error('Invalid terminal session id');
|
|
const existing = sessions.get(id);
|
|
const resolvedCwd = path.resolve(cwd);
|
|
if (existing?.status === 'running') {
|
|
if (path.resolve(existing.cwd) !== resolvedCwd) throw new Error('Terminal session belongs to a different working directory');
|
|
if (!isPurposeActionMatch(getSessionPurpose(existing), normalizedPurpose)) throw new Error('Terminal session is already running with a different purpose');
|
|
if (normalizedPurpose.type === 'project-action') { applyAppearance(existing, { themeMode, terminalBackground, terminalForeground }); return existing; }
|
|
if ((existing.mode ?? INTERACTIVE_TERMINAL_MODE) !== launchMode.mode) throw new Error('Terminal session is already running with a different mode');
|
|
if (launchMode.mode === COMMAND_TERMINAL_MODE && existing.command !== launchMode.command) throw new Error('Terminal session is already running with a different command');
|
|
applyAppearance(existing, { themeMode, terminalBackground, terminalForeground });
|
|
return existing;
|
|
}
|
|
const runningActionSession = findRunningActionSession(sessions, resolvedCwd, normalizedPurpose, path);
|
|
if (runningActionSession) {
|
|
applyAppearance(runningActionSession, { themeMode, terminalBackground, terminalForeground });
|
|
return runningActionSession;
|
|
}
|
|
const pending = pendingSessionCreates.get(id);
|
|
if (pending) {
|
|
if (pending.cwd !== resolvedCwd) throw new Error('Terminal session belongs to a different working directory');
|
|
if (!isPurposeActionMatch(pending.purpose, normalizedPurpose)) throw new Error('Terminal session is already being created with a different purpose');
|
|
if (normalizedPurpose.type === 'project-action') return pending.promise;
|
|
if (pending.shell !== normalizedShell) throw new Error('Terminal session is already being created with a different shell');
|
|
if (pending.loginShell !== loginShell) throw new Error('Terminal session is already being created with a different login mode');
|
|
if (pending.mode !== launchMode.mode) throw new Error('Terminal session is already being created with a different mode');
|
|
if (launchMode.mode === COMMAND_TERMINAL_MODE && pending.command !== launchMode.command) throw new Error('Terminal session is already being created with a different command');
|
|
const session = await pending.promise;
|
|
applyAppearance(session, { themeMode, terminalBackground, terminalForeground });
|
|
return session;
|
|
}
|
|
const pendingActionCreate = findPendingActionCreate(pendingSessionCreates, resolvedCwd, normalizedPurpose);
|
|
if (pendingActionCreate) {
|
|
const session = await pendingActionCreate.promise;
|
|
applyAppearance(session, { themeMode, terminalBackground, terminalForeground });
|
|
return session;
|
|
}
|
|
const superseded = normalizedPurpose.type === 'project-action'
|
|
? [...sessions.values()].filter((session) => session.id !== id && session.status === 'exited'
|
|
&& path.resolve(session.cwd) === resolvedCwd && isPurposeActionMatch(getSessionPurpose(session), normalizedPurpose))
|
|
: [];
|
|
if (!existing && sessions.size - superseded.length + pendingSessionCreates.size >= MAX_SESSIONS) throw new Error('Maximum terminal sessions reached');
|
|
const pendingEntry = { cwd: resolvedCwd, shell: normalizedShell, loginShell, mode: launchMode.mode, command: launchMode.command, purpose: normalizedPurpose, cancelled: false, promise: null };
|
|
const creation = (async () => {
|
|
const session = existing ?? { id, sequence: 0, history: '', pendingHistoryControlSequence: '', pendingThemeControlSequence: '', eventQueue: [], draining: false, createdAt: Date.now() };
|
|
const ptyProcess = await startSession(session, { cwd, cols, rows, themeMode, terminalBackground, terminalForeground, shell: normalizedShell, loginShell, mode: launchMode.mode, command: launchMode.command, purpose: normalizedPurpose });
|
|
if (pendingEntry.cancelled) {
|
|
session.process = null;
|
|
await terminateProcess(ptyProcess, true);
|
|
throw new Error('Terminal session was closed during creation');
|
|
}
|
|
for (const previous of superseded) {
|
|
if (sessions.get(previous.id) !== previous || previous.status !== 'exited') continue;
|
|
sessions.delete(previous.id);
|
|
closeAttachments(previous.id, 'SUPERSEDED', 'Terminal replaced by a new action run');
|
|
}
|
|
sessions.set(id, session);
|
|
return session;
|
|
})();
|
|
pendingEntry.promise = creation;
|
|
pendingSessionCreates.set(id, pendingEntry);
|
|
try { return await creation; }
|
|
finally { if (pendingSessionCreates.get(id) === pendingEntry) pendingSessionCreates.delete(id); }
|
|
};
|
|
|
|
wsServer.on('connection', (socket) => {
|
|
const connection = { socket, attachments: new Map() };
|
|
connections.add(connection);
|
|
send(socket, { t: 'hello', v: 3 });
|
|
const heartbeat = setInterval(() => { try { socket.ping(); } catch { /* closed */ } }, TERMINAL_INPUT_WS_HEARTBEAT_INTERVAL_MS);
|
|
socket.on('message', (raw, isBinary) => {
|
|
if (!isBinary) { send(socket, { t: 'error', v: 3, code: 'BAD_FRAME', message: 'Binary control frame required', fatal: false }); return; }
|
|
const message = readTerminalWsControlFrame(raw);
|
|
if (!message || message.v !== 3 || typeof message.t !== 'string') { send(socket, { t: 'error', v: 3, code: 'BAD_FRAME', message: 'Invalid terminal frame', fatal: false }); return; }
|
|
if (message.t === 'ping') { send(socket, { t: 'pong', v: 3 }); return; }
|
|
if (message.t === 'hello') return;
|
|
const id = typeof message.s === 'string' ? message.s : '';
|
|
if (!id) { send(socket, { t: 'error', v: 3, code: 'BAD_FRAME', message: 'Session id required', fatal: false }); return; }
|
|
if (message.t === 'detach') { connection.attachments.delete(id); return; }
|
|
const session = sessions.get(id);
|
|
if (!session) { send(socket, { t: 'error', v: 3, s: id, code: 'SESSION_NOT_FOUND', message: 'Terminal session not found', fatal: true }); return; }
|
|
if (message.t === 'attach') {
|
|
const attachment = { initializing: true, pending: [] };
|
|
connection.attachments.set(id, attachment);
|
|
const initial = snapshot(session);
|
|
send(socket, initial);
|
|
for (const event of attachment.pending) if (event.q > initial.q) send(socket, event);
|
|
attachment.pending.length = 0; attachment.initializing = false;
|
|
return;
|
|
}
|
|
if (message.t === 'write') {
|
|
if (typeof message.d !== 'string' || !message.d || message.d.length > MAX_INPUT_CHARS) { send(socket, { t: 'error', v: 3, s: id, code: 'BAD_INPUT', message: 'Invalid terminal input', fatal: false }); return; }
|
|
if (session.status !== 'running' || !session.process) { send(socket, { t: 'error', v: 3, s: id, code: 'NOT_RUNNING', message: 'Terminal is not running', fatal: false }); return; }
|
|
try { session.process.write(message.d); session.lastActivity = Date.now(); } catch { send(socket, { t: 'error', v: 3, s: id, code: 'WRITE_FAILED', message: 'Failed to write to terminal', fatal: false }); }
|
|
}
|
|
});
|
|
const cleanup = () => { clearInterval(heartbeat); connection.attachments.clear(); connections.delete(connection); };
|
|
socket.on('close', cleanup); socket.on('error', () => {});
|
|
});
|
|
|
|
const upgradeHandler = (req, socket, head) => {
|
|
if (parseRequestPathname(req.url) !== TERMINAL_WS_PATH) return;
|
|
const accept = () => {
|
|
if (!wsServer) { rejectWebSocketUpgrade(socket, 500, 'Terminal WebSocket unavailable'); return; }
|
|
try {
|
|
wsServer.handleUpgrade(req, socket, head, (ws) => wsServer.emit('connection', ws, req));
|
|
} catch { rejectWebSocketUpgrade(socket, 500, 'Upgrade failed'); }
|
|
};
|
|
const checkOrigin = () => {
|
|
try {
|
|
const result = isRequestOriginAllowed(req);
|
|
if (!(result instanceof Promise)) {
|
|
if (result) accept();
|
|
else rejectWebSocketUpgrade(socket, 403, 'Invalid origin');
|
|
return;
|
|
}
|
|
void result.then((allowed) => {
|
|
if (allowed) accept();
|
|
else rejectWebSocketUpgrade(socket, 403, 'Invalid origin');
|
|
}).catch(() => rejectWebSocketUpgrade(socket, 500, 'Upgrade failed'));
|
|
} catch { rejectWebSocketUpgrade(socket, 500, 'Upgrade failed'); }
|
|
};
|
|
if (!uiAuthController?.enabled) { accept(); return; }
|
|
try {
|
|
const result = uiAuthController.ensureSessionToken(req, null);
|
|
if (!(result instanceof Promise)) {
|
|
if (result) checkOrigin();
|
|
else rejectWebSocketUpgrade(socket, 401, 'UI authentication required');
|
|
return;
|
|
}
|
|
void result.then((sessionToken) => {
|
|
if (sessionToken) checkOrigin();
|
|
else rejectWebSocketUpgrade(socket, 401, 'UI authentication required');
|
|
}).catch(() => rejectWebSocketUpgrade(socket, 500, 'Upgrade failed'));
|
|
} catch { rejectWebSocketUpgrade(socket, 500, 'Upgrade failed'); }
|
|
};
|
|
server.on('upgrade', upgradeHandler);
|
|
|
|
app.get('/api/terminal/shells', async (_req, res) => {
|
|
try {
|
|
const shells = await shellResolver.list();
|
|
res.json(shells.map(({ id, name, supportsLogin }) => ({ id, name, supportsLogin })));
|
|
} catch (error) {
|
|
res.status(500).json({ error: error?.message || 'Failed to list terminal shells' });
|
|
}
|
|
});
|
|
app.get('/api/terminal/sessions', (req, res) => {
|
|
const rawCwd = typeof req.query?.cwd === 'string' ? req.query.cwd.trim() : '';
|
|
const cwdFilter = rawCwd ? path.resolve(rawCwd) : null;
|
|
const list = [];
|
|
for (const session of sessions.values()) {
|
|
if (cwdFilter && path.resolve(session.cwd) !== cwdFilter) continue;
|
|
list.push({
|
|
sessionId: session.id,
|
|
cwd: session.cwd,
|
|
status: session.status,
|
|
createdAt: Number.isInteger(session.createdAt) ? session.createdAt : null,
|
|
mode: session.mode ?? INTERACTIVE_TERMINAL_MODE,
|
|
purpose: getSessionPurpose(session),
|
|
});
|
|
}
|
|
res.json({ sessions: list });
|
|
});
|
|
app.post('/api/terminal/touch', (req, res) => {
|
|
const rawIds = Array.isArray(req.body?.sessionIds) ? req.body.sessionIds : [];
|
|
const now = Date.now();
|
|
let touched = 0;
|
|
for (const id of rawIds) {
|
|
if (typeof id !== 'string') continue;
|
|
const session = sessions.get(id);
|
|
if (!session) continue;
|
|
session.lastActivity = now;
|
|
touched += 1;
|
|
}
|
|
res.json({ touched });
|
|
});
|
|
app.post('/api/terminal/create', async (req, res) => {
|
|
try {
|
|
const session = await createSession(req.body ?? {});
|
|
res.json({
|
|
sessionId: session.id,
|
|
cols: session.cols,
|
|
rows: session.rows,
|
|
status: session.status,
|
|
mode: session.mode ?? INTERACTIVE_TERMINAL_MODE,
|
|
purpose: getSessionPurpose(session),
|
|
});
|
|
}
|
|
catch (error) { res.status(error?.message === 'Maximum terminal sessions reached' ? 429 : 400).json(errorBody(error, 'Failed to create terminal session')); }
|
|
});
|
|
app.post('/api/terminal/:sessionId/resize', (req, res) => {
|
|
const session = sessions.get(req.params.sessionId);
|
|
if (!session) return res.status(404).json({ error: 'Terminal session not found' });
|
|
const { cols, rows } = req.body ?? {};
|
|
if (!validateSize(cols, 1000) || !validateSize(rows, 500)) return res.status(400).json({ error: 'Invalid terminal dimensions' });
|
|
try { if (session.status === 'running') session.process?.resize(cols, rows); session.cols = cols; session.rows = rows; res.json({ success: true, cols, rows }); }
|
|
catch (error) { res.status(500).json({ error: error?.message || 'Failed to resize terminal' }); }
|
|
});
|
|
app.post('/api/terminal/:sessionId/appearance', (req, res) => {
|
|
const session = sessions.get(req.params.sessionId);
|
|
if (!session) return res.status(404).json({ error: 'Terminal session not found' });
|
|
applyAppearance(session, req.body ?? {});
|
|
res.json({ success: true });
|
|
});
|
|
app.post('/api/terminal/:sessionId/restart', async (req, res) => {
|
|
const session = sessions.get(req.params.sessionId);
|
|
if (!session) return res.status(404).json({ error: 'Terminal session not found' });
|
|
if ((session.mode ?? INTERACTIVE_TERMINAL_MODE) === COMMAND_TERMINAL_MODE) return res.status(400).json({ error: 'Command-mode terminal sessions cannot be restarted' });
|
|
const cwd = req.body?.cwd ?? session.cwd;
|
|
const cols = req.body?.cols ?? session.cols;
|
|
const rows = req.body?.rows ?? session.rows;
|
|
const themeMode = req.body?.themeMode ?? session.themeMode;
|
|
const terminalBackground = req.body?.terminalBackground ?? session.terminalBackground;
|
|
const terminalForeground = req.body?.terminalForeground ?? session.terminalForeground;
|
|
const shell = req.body?.shell ?? 'auto';
|
|
const loginShell = req.body?.loginShell ?? false;
|
|
const previousRestart = pendingSessionRestarts.get(session.id) ?? Promise.resolve();
|
|
const restart = previousRestart.catch(() => {}).then(async () => {
|
|
await validateCwd(cwd);
|
|
if (!validateSize(cols, 1000) || !validateSize(rows, 500)) throw new Error('Invalid terminal dimensions');
|
|
if (typeof loginShell !== 'boolean') throw new Error('Invalid terminal login mode');
|
|
const oldProcess = session.process;
|
|
const spawned = await spawnPty({ cwd, cols, rows, themeMode, shell, loginShell });
|
|
session.process = spawned.process; session.backend = spawned.backend; session.shell = spawned.shell; session.loginShell = spawned.loginShell; session.cwd = cwd; session.cols = cols; session.rows = rows;
|
|
session.history = ''; session.pendingHistoryControlSequence = ''; session.pendingThemeControlSequence = ''; session.themeModeEnabled = false; session.status = 'running'; session.exitCode = null; session.signal = null; session.eventQueue.length = 0;
|
|
session.themeMode = themeMode === 'light' ? 'light' : 'dark'; session.terminalBackground = terminalBackground; session.terminalForeground = terminalForeground;
|
|
wire(session, spawned.process); void terminateProcess(oldProcess); publish(session, { t: 'restarted', history: '' });
|
|
});
|
|
pendingSessionRestarts.set(session.id, restart);
|
|
try {
|
|
await restart;
|
|
res.json({ sessionId: session.id, cols, rows, status: session.status });
|
|
} catch (error) { res.status(400).json(errorBody(error, 'Failed to restart terminal')); }
|
|
finally { if (pendingSessionRestarts.get(session.id) === restart) pendingSessionRestarts.delete(session.id); }
|
|
});
|
|
app.delete('/api/terminal/:sessionId', async (req, res) => {
|
|
const session = sessions.get(req.params.sessionId);
|
|
if (!session) {
|
|
const pending = pendingSessionCreates.get(req.params.sessionId);
|
|
if (!pending) return res.status(404).json({ error: 'Terminal session not found' });
|
|
pending.cancelled = true;
|
|
try { await pending.promise; }
|
|
catch (error) {
|
|
if (error?.message !== 'Terminal session was closed during creation') throw error;
|
|
}
|
|
return res.json({ success: true });
|
|
}
|
|
sessions.delete(session.id);
|
|
closeAttachments(session.id, 'CLOSED', 'Terminal closed');
|
|
await terminateProcess(session.process);
|
|
res.json({ success: true });
|
|
});
|
|
app.post('/api/terminal/force-kill', (req, res) => {
|
|
const { sessionId, cwd } = req.body ?? {}; let killedCount = 0;
|
|
const killedSessionIds = [];
|
|
for (const [id, session] of sessions) {
|
|
if ((sessionId && id !== sessionId) || (!sessionId && cwd && session.cwd !== cwd)) continue;
|
|
sessions.delete(id); closeAttachments(id, 'KILLED', 'Terminal was killed'); void terminateProcess(session.process, true); killedSessionIds.push(id); killedCount += 1;
|
|
}
|
|
res.json({ success: true, killedCount, killedSessionIds });
|
|
});
|
|
|
|
const idleSweep = setInterval(() => {
|
|
const now = Date.now();
|
|
for (const [id, session] of sessions) {
|
|
const attached = [...connections].some((connection) => connection.attachments.has(id));
|
|
if (!attached && now - session.lastActivity > IDLE_TIMEOUT_MS) {
|
|
sessions.delete(id); closeAttachments(id, 'IDLE_TIMEOUT', 'Terminal expired after being idle'); void terminateProcess(session.process, true);
|
|
}
|
|
}
|
|
}, 5 * 60 * 1000);
|
|
|
|
const shutdown = async () => {
|
|
server.off('upgrade', upgradeHandler); clearInterval(idleSweep);
|
|
await Promise.allSettled([...pendingSessionRestarts.values()]);
|
|
for (const session of sessions.values()) void terminateProcess(session.process, true);
|
|
sessions.clear();
|
|
await Promise.allSettled([...pendingTerminations]);
|
|
if (!wsServer) return;
|
|
for (const client of wsServer.clients) client.terminate();
|
|
await Promise.race([
|
|
new Promise((resolve) => wsServer.close(resolve)),
|
|
new Promise((resolve) => setTimeout(resolve, 1000)),
|
|
]);
|
|
wsServer = null;
|
|
};
|
|
return { shutdown };
|
|
}
|