feat(KB-058): complete Step 2 — create TerminalService with node-pty
This commit is contained in:
587
packages/dashboard/src/terminal-service.ts
Normal file
587
packages/dashboard/src/terminal-service.ts
Normal file
@@ -0,0 +1,587 @@
|
||||
/**
|
||||
* Terminal Service
|
||||
*
|
||||
* Manages PTY (pseudo-terminal) sessions using node-pty.
|
||||
* Supports cross-platform shell detection and secure session management.
|
||||
*/
|
||||
|
||||
import * as pty from "node-pty";
|
||||
import { EventEmitter } from "events";
|
||||
import * as os from "os";
|
||||
import * as path from "path";
|
||||
import { existsSync } from "node:fs";
|
||||
|
||||
// Maximum scrollback buffer size (characters)
|
||||
const MAX_SCROLLBACK_SIZE = 50000; // ~50KB per terminal
|
||||
|
||||
// Session limit constants
|
||||
const MIN_MAX_SESSIONS = 1;
|
||||
const MAX_MAX_SESSIONS = 100;
|
||||
const DEFAULT_MAX_SESSIONS = 10;
|
||||
|
||||
// Throttle output to prevent overwhelming WebSocket under heavy load
|
||||
const OUTPUT_THROTTLE_MS = 4; // ~250fps max update rate for responsive input
|
||||
const OUTPUT_BATCH_SIZE = 4096; // Smaller batches for lower latency
|
||||
|
||||
// Valid session ID pattern (alphanumeric and dashes only)
|
||||
const SESSION_ID_PATTERN = /^[a-zA-Z0-9-]+$/;
|
||||
|
||||
// Allowed shell paths for security
|
||||
const ALLOWED_SHELL_PATHS: Record<string, string[]> = {
|
||||
darwin: ["/bin/bash", "/bin/zsh", "/bin/sh", "/usr/local/bin/bash", "/usr/local/bin/zsh"],
|
||||
linux: ["/bin/bash", "/bin/zsh", "/bin/sh", "/usr/bin/bash", "/usr/bin/zsh", "/usr/bin/sh"],
|
||||
win32: [
|
||||
"C:\\Windows\\System32\\cmd.exe",
|
||||
"C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe",
|
||||
"powershell.exe",
|
||||
"cmd.exe",
|
||||
],
|
||||
};
|
||||
|
||||
// Environment variables to strip from user shells
|
||||
const STRIP_ENV_VARS = [
|
||||
"PORT",
|
||||
"DATA_DIR",
|
||||
"AUTOMAKER_API_KEY",
|
||||
"NODE_PATH",
|
||||
"GITHUB_TOKEN",
|
||||
"KB_API_KEY",
|
||||
];
|
||||
|
||||
export interface TerminalSession {
|
||||
id: string;
|
||||
pty: pty.IPty;
|
||||
cwd: string;
|
||||
createdAt: Date;
|
||||
shell: string;
|
||||
scrollbackBuffer: string;
|
||||
outputBuffer: string;
|
||||
flushTimeout: NodeJS.Timeout | null;
|
||||
resizeInProgress: boolean;
|
||||
resizeDebounceTimeout: NodeJS.Timeout | null;
|
||||
}
|
||||
|
||||
export interface TerminalOptions {
|
||||
cwd?: string;
|
||||
shell?: string;
|
||||
cols?: number;
|
||||
rows?: number;
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
|
||||
type DataCallback = (sessionId: string, data: string) => void;
|
||||
type ExitCallback = (sessionId: string, exitCode: number) => void;
|
||||
|
||||
export class TerminalService extends EventEmitter {
|
||||
private sessions: Map<string, TerminalSession> = new Map();
|
||||
private dataCallbacks: Set<DataCallback> = new Set();
|
||||
private exitCallbacks: Set<ExitCallback> = new Set();
|
||||
private isWindows = os.platform() === "win32";
|
||||
private projectRoot: string;
|
||||
private maxSessions: number;
|
||||
|
||||
constructor(projectRoot: string, maxSessions: number = DEFAULT_MAX_SESSIONS) {
|
||||
super();
|
||||
this.projectRoot = path.resolve(projectRoot);
|
||||
this.maxSessions = Math.max(MIN_MAX_SESSIONS, Math.min(maxSessions, MAX_MAX_SESSIONS));
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill a PTY process with platform-specific handling.
|
||||
* Windows doesn't support Unix signals like SIGTERM/SIGKILL.
|
||||
*/
|
||||
private killPtyProcess(ptyProcess: pty.IPty, signal: string = "SIGTERM"): void {
|
||||
if (this.isWindows) {
|
||||
ptyProcess.kill();
|
||||
} else {
|
||||
ptyProcess.kill(signal);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default allowed shells for the current platform
|
||||
*/
|
||||
private getAllowedShells(): string[] {
|
||||
const platform = os.platform();
|
||||
return ALLOWED_SHELL_PATHS[platform] || ALLOWED_SHELL_PATHS.linux;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a shell path is allowed
|
||||
*/
|
||||
private isAllowedShell(shellPath: string): boolean {
|
||||
const allowed = this.getAllowedShells();
|
||||
const normalized = this.isWindows ? shellPath.toLowerCase() : shellPath;
|
||||
return allowed.some((s) => (this.isWindows ? s.toLowerCase() : s) === normalized);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the best shell for the current platform
|
||||
*/
|
||||
detectShell(): { shell: string; args: string[] } {
|
||||
const platform = os.platform();
|
||||
const allowedShells = this.getAllowedShells();
|
||||
|
||||
// Helper to get basename handling both path separators
|
||||
const getBasename = (shellPath: string): string => {
|
||||
const lastSep = Math.max(shellPath.lastIndexOf("/"), shellPath.lastIndexOf("\\"));
|
||||
return lastSep >= 0 ? shellPath.slice(lastSep + 1) : shellPath;
|
||||
};
|
||||
|
||||
// Helper to get shell args based on shell name
|
||||
const getShellArgs = (shell: string): string[] => {
|
||||
const shellName = getBasename(shell).toLowerCase().replace(".exe", "");
|
||||
// PowerShell and cmd don't need --login
|
||||
if (shellName === "powershell" || shellName === "pwsh" || shellName === "cmd") {
|
||||
return [];
|
||||
}
|
||||
// sh doesn't support --login in all implementations
|
||||
if (shellName === "sh") {
|
||||
return [];
|
||||
}
|
||||
// bash, zsh, and other POSIX shells support --login
|
||||
return ["--login"];
|
||||
};
|
||||
|
||||
// First try user's shell from env if it's allowed
|
||||
const userShell = process.env.SHELL;
|
||||
if (userShell && platform !== "win32") {
|
||||
const normalizedUserShell = this.isWindows ? userShell.toLowerCase() : userShell;
|
||||
for (const allowed of allowedShells) {
|
||||
const normalizedAllowed = this.isWindows ? allowed.toLowerCase() : allowed;
|
||||
if (normalizedAllowed === normalizedUserShell && existsSync(allowed)) {
|
||||
return { shell: allowed, args: getShellArgs(allowed) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Iterate through allowed shell paths and return first existing one
|
||||
for (const shell of allowedShells) {
|
||||
if (existsSync(shell)) {
|
||||
return { shell, args: getShellArgs(shell) };
|
||||
}
|
||||
}
|
||||
|
||||
// Ultimate fallbacks based on platform
|
||||
if (platform === "win32") {
|
||||
return { shell: "cmd.exe", args: [] };
|
||||
}
|
||||
return { shell: "/bin/sh", args: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and resolve a working directory path
|
||||
*/
|
||||
private async resolveWorkingDirectory(requestedCwd?: string): Promise<string> {
|
||||
// If no cwd requested, use project root
|
||||
if (!requestedCwd) {
|
||||
return this.projectRoot;
|
||||
}
|
||||
|
||||
// Clean up the path
|
||||
let cwd = requestedCwd.trim();
|
||||
|
||||
// Reject paths with null bytes (could bypass path checks)
|
||||
if (cwd.includes("\0")) {
|
||||
console.warn(`Rejecting path with null byte: ${cwd.replace(/\0/g, "\\0")}`);
|
||||
return this.projectRoot;
|
||||
}
|
||||
|
||||
// Normalize the path to resolve . and .. segments
|
||||
cwd = path.resolve(this.projectRoot, cwd);
|
||||
|
||||
// Ensure path is within project root (path traversal protection)
|
||||
if (!cwd.startsWith(this.projectRoot)) {
|
||||
console.warn(`Path traversal attempt blocked: ${requestedCwd}`);
|
||||
return this.projectRoot;
|
||||
}
|
||||
|
||||
// Check if path exists and is a directory
|
||||
try {
|
||||
const stat = await import("node:fs/promises").then((fs) => fs.stat(cwd));
|
||||
if (stat.isDirectory()) {
|
||||
return cwd;
|
||||
}
|
||||
} catch {
|
||||
// Path doesn't exist, fall back to project root
|
||||
console.warn(`Working directory does not exist: ${cwd}`);
|
||||
}
|
||||
|
||||
return this.projectRoot;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate session ID format
|
||||
*/
|
||||
private isValidSessionId(sessionId: string): boolean {
|
||||
return SESSION_ID_PATTERN.test(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current session count
|
||||
*/
|
||||
getSessionCount(): number {
|
||||
return this.sessions.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get maximum allowed sessions
|
||||
*/
|
||||
getMaxSessions(): number {
|
||||
return this.maxSessions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set maximum allowed sessions
|
||||
*/
|
||||
setMaxSessions(limit: number): void {
|
||||
if (limit >= MIN_MAX_SESSIONS && limit <= MAX_MAX_SESSIONS) {
|
||||
this.maxSessions = limit;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new terminal session
|
||||
*/
|
||||
async createSession(options: TerminalOptions = {}): Promise<TerminalSession | null> {
|
||||
// Check session limit
|
||||
if (this.sessions.size >= this.maxSessions) {
|
||||
console.error(`Max sessions (${this.maxSessions}) reached, refusing new session`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const id = `term-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
|
||||
|
||||
const { shell: detectedShell, args: shellArgs } = this.detectShell();
|
||||
const shell = options.shell || detectedShell;
|
||||
|
||||
// Validate shell is allowed
|
||||
if (!this.isAllowedShell(shell)) {
|
||||
console.error(`Shell not allowed: ${shell}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Validate and resolve working directory
|
||||
const cwd = await this.resolveWorkingDirectory(options.cwd);
|
||||
|
||||
// Build environment with stripped sensitive vars
|
||||
const cleanEnv: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(process.env)) {
|
||||
if (value !== undefined && !STRIP_ENV_VARS.includes(key)) {
|
||||
cleanEnv[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
const env: Record<string, string> = {
|
||||
...cleanEnv,
|
||||
TERM: "xterm-256color",
|
||||
COLORTERM: "truecolor",
|
||||
TERM_PROGRAM: "kb-terminal",
|
||||
LANG: process.env.LANG || "en_US.UTF-8",
|
||||
LC_ALL: process.env.LC_ALL || process.env.LANG || "en_US.UTF-8",
|
||||
...options.env,
|
||||
};
|
||||
|
||||
console.info(`Creating session ${id} with shell: ${shell} in ${cwd}`);
|
||||
|
||||
// Build PTY spawn options
|
||||
const ptyOptions: pty.IPtyForkOptions = {
|
||||
name: "xterm-256color",
|
||||
cols: options.cols || 80,
|
||||
rows: options.rows || 24,
|
||||
cwd,
|
||||
env,
|
||||
};
|
||||
|
||||
// On Windows, use winpty instead of ConPTY for compatibility
|
||||
if (this.isWindows) {
|
||||
(ptyOptions as pty.IWindowsPtyForkOptions).useConpty = false;
|
||||
}
|
||||
|
||||
let ptyProcess: pty.IPty;
|
||||
try {
|
||||
ptyProcess = pty.spawn(shell, shellArgs, ptyOptions);
|
||||
} catch (spawnError) {
|
||||
console.error(`[createSession] PTY spawn failed:`, spawnError);
|
||||
return null;
|
||||
}
|
||||
|
||||
const session: TerminalSession = {
|
||||
id,
|
||||
pty: ptyProcess,
|
||||
cwd,
|
||||
createdAt: new Date(),
|
||||
shell,
|
||||
scrollbackBuffer: "",
|
||||
outputBuffer: "",
|
||||
flushTimeout: null,
|
||||
resizeInProgress: false,
|
||||
resizeDebounceTimeout: null,
|
||||
};
|
||||
|
||||
this.sessions.set(id, session);
|
||||
|
||||
// Flush buffered output to clients (throttled)
|
||||
const flushOutput = () => {
|
||||
if (session.outputBuffer.length === 0) return;
|
||||
|
||||
let dataToSend = session.outputBuffer;
|
||||
if (dataToSend.length > OUTPUT_BATCH_SIZE) {
|
||||
dataToSend = session.outputBuffer.slice(0, OUTPUT_BATCH_SIZE);
|
||||
session.outputBuffer = session.outputBuffer.slice(OUTPUT_BATCH_SIZE);
|
||||
session.flushTimeout = setTimeout(flushOutput, OUTPUT_THROTTLE_MS);
|
||||
} else {
|
||||
session.outputBuffer = "";
|
||||
session.flushTimeout = null;
|
||||
}
|
||||
|
||||
this.dataCallbacks.forEach((cb) => cb(id, dataToSend));
|
||||
this.emit("data", id, dataToSend);
|
||||
};
|
||||
|
||||
// Forward data events with throttling
|
||||
ptyProcess.onData((data) => {
|
||||
if (session.resizeInProgress) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Append to scrollback buffer
|
||||
session.scrollbackBuffer += data;
|
||||
if (session.scrollbackBuffer.length > MAX_SCROLLBACK_SIZE) {
|
||||
session.scrollbackBuffer = session.scrollbackBuffer.slice(-MAX_SCROLLBACK_SIZE);
|
||||
}
|
||||
|
||||
// Buffer output for throttled delivery
|
||||
session.outputBuffer += data;
|
||||
|
||||
if (!session.flushTimeout) {
|
||||
session.flushTimeout = setTimeout(flushOutput, OUTPUT_THROTTLE_MS);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle exit
|
||||
ptyProcess.onExit(({ exitCode }) => {
|
||||
console.info(`Session exited with code ${exitCode ?? 0} (${id})`);
|
||||
this.sessions.delete(id);
|
||||
this.exitCallbacks.forEach((cb) => cb(id, exitCode ?? 0));
|
||||
this.emit("exit", id, exitCode ?? 0);
|
||||
});
|
||||
|
||||
console.info(`Session ${id} created successfully`);
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write data to a terminal session
|
||||
*/
|
||||
write(sessionId: string, data: string): boolean {
|
||||
if (!this.isValidSessionId(sessionId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) {
|
||||
console.warn(`Session ${sessionId} not found`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Reject data with null bytes
|
||||
if (data.includes("\0")) {
|
||||
console.warn(`Rejecting input with null byte to session ${sessionId}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
session.pty.write(data);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize a terminal session
|
||||
*/
|
||||
resize(sessionId: string, cols: number, rows: number, suppressOutput: boolean = true): boolean {
|
||||
if (!this.isValidSessionId(sessionId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) {
|
||||
console.warn(`Session ${sessionId} not found for resize`);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
if (suppressOutput) {
|
||||
session.resizeInProgress = true;
|
||||
if (session.resizeDebounceTimeout) {
|
||||
clearTimeout(session.resizeDebounceTimeout);
|
||||
}
|
||||
}
|
||||
|
||||
session.pty.resize(cols, rows);
|
||||
|
||||
if (suppressOutput) {
|
||||
session.resizeDebounceTimeout = setTimeout(() => {
|
||||
session.resizeInProgress = false;
|
||||
session.resizeDebounceTimeout = null;
|
||||
}, 150);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`Error resizing session ${sessionId}:`, error);
|
||||
session.resizeInProgress = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill a terminal session
|
||||
*/
|
||||
killSession(sessionId: string): boolean {
|
||||
if (!this.isValidSessionId(sessionId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Clean up flush timeout
|
||||
if (session.flushTimeout) {
|
||||
clearTimeout(session.flushTimeout);
|
||||
session.flushTimeout = null;
|
||||
}
|
||||
|
||||
if (session.resizeDebounceTimeout) {
|
||||
clearTimeout(session.resizeDebounceTimeout);
|
||||
session.resizeDebounceTimeout = null;
|
||||
}
|
||||
|
||||
// First try graceful SIGTERM
|
||||
console.info(`Session ${sessionId} sending SIGTERM`);
|
||||
this.killPtyProcess(session.pty, "SIGTERM");
|
||||
|
||||
// Schedule SIGKILL fallback
|
||||
setTimeout(() => {
|
||||
if (this.sessions.has(sessionId)) {
|
||||
console.info(`Session ${sessionId} still alive after SIGTERM, sending SIGKILL`);
|
||||
try {
|
||||
this.killPtyProcess(session.pty, "SIGKILL");
|
||||
} catch {
|
||||
// Process may have already exited
|
||||
}
|
||||
this.sessions.delete(sessionId);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`Error killing session ${sessionId}:`, error);
|
||||
this.sessions.delete(sessionId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a session by ID
|
||||
*/
|
||||
getSession(sessionId: string): TerminalSession | undefined {
|
||||
if (!this.isValidSessionId(sessionId)) {
|
||||
return undefined;
|
||||
}
|
||||
return this.sessions.get(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get scrollback buffer for a session
|
||||
*/
|
||||
getScrollback(sessionId: string): string | null {
|
||||
if (!this.isValidSessionId(sessionId)) {
|
||||
return null;
|
||||
}
|
||||
const session = this.sessions.get(sessionId);
|
||||
return session?.scrollbackBuffer || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get scrollback and clear pending output buffer
|
||||
*/
|
||||
getScrollbackAndClearPending(sessionId: string): string | null {
|
||||
if (!this.isValidSessionId(sessionId)) {
|
||||
return null;
|
||||
}
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return null;
|
||||
|
||||
session.outputBuffer = "";
|
||||
if (session.flushTimeout) {
|
||||
clearTimeout(session.flushTimeout);
|
||||
session.flushTimeout = null;
|
||||
}
|
||||
|
||||
return session.scrollbackBuffer || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active sessions
|
||||
*/
|
||||
getAllSessions(): Array<{ id: string; cwd: string; createdAt: Date; shell: string }> {
|
||||
return Array.from(this.sessions.values()).map((s) => ({
|
||||
id: s.id,
|
||||
cwd: s.cwd,
|
||||
createdAt: s.createdAt,
|
||||
shell: s.shell,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to data events
|
||||
*/
|
||||
onData(callback: DataCallback): () => void {
|
||||
this.dataCallbacks.add(callback);
|
||||
return () => this.dataCallbacks.delete(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to exit events
|
||||
*/
|
||||
onExit(callback: ExitCallback): () => void {
|
||||
this.exitCallbacks.add(callback);
|
||||
return () => this.exitCallbacks.delete(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up all sessions
|
||||
*/
|
||||
cleanup(): void {
|
||||
console.info(`Cleaning up ${this.sessions.size} sessions`);
|
||||
this.sessions.forEach((session, id) => {
|
||||
try {
|
||||
if (session.flushTimeout) {
|
||||
clearTimeout(session.flushTimeout);
|
||||
}
|
||||
this.killPtyProcess(session.pty);
|
||||
} catch {
|
||||
// Ignore errors during cleanup
|
||||
}
|
||||
this.sessions.delete(id);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance (initialized lazily with project root)
|
||||
let terminalService: TerminalService | null = null;
|
||||
let initializedRoot: string | null = null;
|
||||
|
||||
export function getTerminalService(projectRoot?: string, maxSessions?: number): TerminalService {
|
||||
if (!terminalService || (projectRoot && projectRoot !== initializedRoot)) {
|
||||
if (!projectRoot) {
|
||||
throw new Error("TerminalService requires projectRoot for initialization");
|
||||
}
|
||||
terminalService = new TerminalService(projectRoot, maxSessions);
|
||||
initializedRoot = projectRoot;
|
||||
}
|
||||
return terminalService;
|
||||
}
|
||||
Reference in New Issue
Block a user