UNPKG

@shelltender/server

Version:

Server-side terminal session management for Shelltender

1,665 lines (1,644 loc) 102 kB
// src/SessionManager.ts import * as pty from "node-pty"; import { v4 as uuidv4 } from "uuid"; import { EventEmitter } from "events"; // src/RestrictedShell.ts import * as path from "path"; import * as fs from "fs"; var RestrictedShell = class { constructor(options) { this.options = options; this.restrictedPath = options.restrictToPath ? path.resolve(options.restrictToPath) : options.cwd || process.env.HOME || "/"; this.allowUpward = options.allowUpwardNavigation ?? !options.restrictToPath; this.blockedCommands = new Set(options.blockedCommands || [ "sudo", "su", "chmod", "chown", "mount", "umount" ]); if (options.readOnlyMode) { this.addReadOnlyRestrictions(); } } addReadOnlyRestrictions() { const writeCommands = [ "rm", "rmdir", "mv", "cp", "mkdir", "touch", "dd", "nano", "vim", "vi", "emacs", ">", ">>" ]; writeCommands.forEach((cmd) => this.blockedCommands.add(cmd)); } // Create initialization script for the shell getInitScript() { const scripts = []; if (this.options.restrictToPath) { scripts.push(` # Restrict navigation export RESTRICTED_PATH="${this.restrictedPath}" # Override cd command cd() { local target="$1" if [ -z "$target" ]; then target="$RESTRICTED_PATH" fi # Resolve the absolute path local abs_path=$(realpath -m "$target" 2>/dev/null || echo "$target") # Check if path is within restricted area if [[ ! "$abs_path" =~ ^"$RESTRICTED_PATH" ]]; then echo "Access denied: Cannot navigate outside restricted area" >&2 return 1 fi # Use builtin cd builtin cd "$target" } # Override pwd to show relative path pwd() { local current=$(builtin pwd) if [[ "$current" =~ ^"$RESTRICTED_PATH" ]]; then echo "\${current#$RESTRICTED_PATH}" | sed 's/^$/\\//' else echo "/" fi } `); } if (this.blockedCommands.size > 0) { for (const cmd of this.blockedCommands) { scripts.push(` ${cmd}() { echo "Command '${cmd}' is not allowed in this session" >&2 return 1 } `); } } if (this.options.readOnlyMode) { scripts.push(` # Redirect write operations set -o noclobber # Prevent overwriting files # Make common directories read-only alias rm='echo "Write operations are disabled" >&2; false' alias touch='echo "Write operations are disabled" >&2; false' `); } if (this.options.restrictToPath || this.options.readOnlyMode) { scripts.push(` unset HISTFILE export HISTSIZE=0 `); } return scripts.join("\n"); } // Validate a command before execution validateCommand(command) { const parts = command.trim().split(/\s+/); const cmd = parts[0]; if (this.blockedCommands.has(cmd)) { return { allowed: false, reason: `Command '${cmd}' is not allowed in this session` }; } if (this.options.restrictToPath && !this.allowUpward) { if (command.includes("../") || command.includes("..\\")) { return { allowed: false, reason: "Path traversal is not allowed" }; } } if (this.options.restrictToPath) { const absolutePathRegex = /\/[^\s]+/g; const matches = command.match(absolutePathRegex) || []; for (const match of matches) { const absPath = path.resolve(match); if (!absPath.startsWith(this.restrictedPath)) { return { allowed: false, reason: `Access to path '${match}' is not allowed` }; } } } return { allowed: true }; } // Get the shell command and args getShellCommand() { const initScript = this.getInitScript(); const tempInitFile = `/tmp/.terminal_init_${Date.now()}.sh`; fs.writeFileSync(tempInitFile, initScript); return { command: this.options.command || "/bin/bash", args: [ "--rcfile", tempInitFile, ...this.options.args || [] ], env: { ...this.options.env, ...this.options.restrictToPath && { PS1: "[Restricted] \\w\\$ " } } }; } }; // src/SessionManager.ts var SessionManager = class extends EventEmitter { constructor(sessionStore) { super(); this.sessions = /* @__PURE__ */ new Map(); this.restoredSessions = /* @__PURE__ */ new Set(); this.sessionStore = sessionStore; this.setMaxListeners(100); this.restoreSessions(); } async restoreSessions() { const savedSessions = await this.sessionStore.loadAllSessions(); for (const [sessionId, storedSession] of savedSessions) { try { const env = { ...process.env, LANG: "en_US.UTF-8", LC_ALL: "en_US.UTF-8", LC_CTYPE: "en_US.UTF-8", TERM: "xterm-256color" }; const ptyProcess = pty.spawn("/bin/bash", [], { name: "xterm-256color", cols: storedSession.session.cols, rows: storedSession.session.rows, cwd: storedSession.cwd || process.env.HOME, env }); const session = { ...storedSession.session, id: sessionId, lastAccessedAt: /* @__PURE__ */ new Date() }; this.sessions.set(sessionId, { pty: ptyProcess, session, clients: /* @__PURE__ */ new Set() }); this.setupPtyHandlers(sessionId, ptyProcess); this.restoredSessions.add(sessionId); if (storedSession.buffer) { this.emit("data", sessionId, storedSession.buffer, { source: "restored" }); } } catch (error) { console.error(`Failed to restore session ${sessionId}:`, error); await this.sessionStore.deleteSession(sessionId); } } } setupPtyHandlers(sessionId, ptyProcess) { let saveTimer = null; let hasReceivedOutput = false; ptyProcess.onData((data) => { this.emit("data", sessionId, data, { source: "pty" }); if (this.restoredSessions.has(sessionId) && !hasReceivedOutput) { hasReceivedOutput = true; this.restoredSessions.delete(sessionId); } }); ptyProcess.onExit(() => { this.emit("sessionEnd", sessionId); this.sessions.delete(sessionId); this.restoredSessions.delete(sessionId); this.sessionStore.deleteSession(sessionId); }); } createSession(options = {}) { const cols = options.cols || 80; const rows = options.rows || 24; const sessionId = options.id || uuidv4(); const session = { id: sessionId, createdAt: /* @__PURE__ */ new Date(), lastAccessedAt: /* @__PURE__ */ new Date(), cols, rows, command: options.command, args: options.args, locked: options.locked }; let command = options.command || process.env.SHELL || "/bin/sh"; let args = options.args || []; let cwd = options.cwd || process.cwd(); let env = { ...process.env, ...options.env, LANG: "en_US.UTF-8", LC_ALL: "en_US.UTF-8", LC_CTYPE: "en_US.UTF-8", TERM: "xterm-256color" }; if (options.restrictToPath || options.blockedCommands || options.readOnlyMode) { const restrictedShell = new RestrictedShell(options); const shellConfig = restrictedShell.getShellCommand(); command = shellConfig.command; args = shellConfig.args; env = { ...env, ...shellConfig.env }; } let ptyProcess; try { ptyProcess = pty.spawn(command, args, { name: "xterm-256color", cols, rows, cwd, env }); } catch (error) { const errorMessage = `Failed to create PTY session: ${error instanceof Error ? error.message : String(error)}`; const debugInfo = { command, args, cwd, cols, rows, platform: process.platform, shell: command, error: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : void 0 }; console.error(errorMessage, debugInfo); if (error instanceof Error && error.message.includes("ENOENT")) { throw new Error(`Shell not found: ${command}. Try using /bin/sh or install ${command}`); } throw new Error(`${errorMessage} (command: ${command}, cwd: ${cwd})`); } this.sessions.set(sessionId, { pty: ptyProcess, session, clients: /* @__PURE__ */ new Set() }); this.setupPtyHandlers(sessionId, ptyProcess); this.sessionStore.saveSession(sessionId, session, "", cwd); return session; } getSession(sessionId) { const processInfo = this.sessions.get(sessionId); if (processInfo) { processInfo.session.lastAccessedAt = /* @__PURE__ */ new Date(); return processInfo.session; } return null; } writeToSession(sessionId, data) { const processInfo = this.sessions.get(sessionId); if (processInfo) { try { processInfo.pty.write(data); return true; } catch (error) { return false; } } return false; } // Send a command to a session (adds newline automatically) sendCommand(sessionId, command) { return this.writeToSession(sessionId, command + "\n"); } // Send raw input without modification sendRawInput(sessionId, data) { return this.writeToSession(sessionId, data); } // Send special keys sendKey(sessionId, key) { const keyMap = { "ctrl-c": "", "ctrl-d": "", "ctrl-z": "", "ctrl-r": "", "tab": " ", "escape": "\x1B", "up": "\x1B[A", "down": "\x1B[B", "left": "\x1B[D", "right": "\x1B[C" }; const sequence = keyMap[key]; if (!sequence) return false; return this.writeToSession(sessionId, sequence); } resizeSession(sessionId, cols, rows) { const processInfo = this.sessions.get(sessionId); if (processInfo) { processInfo.pty.resize(cols, rows); processInfo.session.cols = cols; processInfo.session.rows = rows; return true; } return false; } addClient(sessionId, clientId) { const processInfo = this.sessions.get(sessionId); if (processInfo) { processInfo.clients.add(clientId); } } removeClient(sessionId, clientId) { const processInfo = this.sessions.get(sessionId); if (processInfo) { processInfo.clients.delete(clientId); } } getAllSessions() { return Array.from(this.sessions.values()).map((p) => p.session); } killSession(sessionId) { const processInfo = this.sessions.get(sessionId); if (processInfo) { processInfo.pty.kill(); this.sessions.delete(sessionId); this.emit("sessionEnd", sessionId); this.sessionStore.deleteSession(sessionId); return true; } return false; } // Implement IDataEmitter interface methods onData(callback) { this.on("data", callback); return () => this.off("data", callback); } onSessionEnd(callback) { this.on("sessionEnd", callback); return () => this.off("sessionEnd", callback); } getActiveSessionIds() { return Array.from(this.sessions.keys()); } getSessionMetadata(sessionId) { const processInfo = this.sessions.get(sessionId); if (!processInfo) return null; return { id: sessionId, command: processInfo.session.command || "/bin/bash", args: processInfo.session.args || [], createdAt: processInfo.session.createdAt, isActive: true }; } getAllSessionMetadata() { return Array.from(this.sessions.keys()).map((id) => this.getSessionMetadata(id)).filter(Boolean); } }; // src/BufferManager.ts var BufferManager = class { constructor(maxBufferSize = 1e5) { this.buffers = /* @__PURE__ */ new Map(); this.sequencedBuffers = /* @__PURE__ */ new Map(); this.maxBufferSize = maxBufferSize; } /** * Set the event manager for pattern matching */ setEventManager(eventManager) { this.eventManager = eventManager; } addToBuffer(sessionId, data) { if (!this.buffers.has(sessionId)) { this.buffers.set(sessionId, ""); } let buffer = this.buffers.get(sessionId); buffer += data; if (buffer.length > this.maxBufferSize) { buffer = buffer.slice(buffer.length - this.maxBufferSize); } this.buffers.set(sessionId, buffer); if (!this.sequencedBuffers.has(sessionId)) { this.sequencedBuffers.set(sessionId, { entries: [], nextSequence: 0, totalSize: 0 }); } const sessionBuffer = this.sequencedBuffers.get(sessionId); const sequence = sessionBuffer.nextSequence++; sessionBuffer.entries.push({ sequence, data, timestamp: Date.now() }); sessionBuffer.totalSize += data.length; while (sessionBuffer.totalSize > this.maxBufferSize && sessionBuffer.entries.length > 0) { const removed = sessionBuffer.entries.shift(); sessionBuffer.totalSize -= removed.data.length; } if (this.eventManager) { setImmediate(() => { this.eventManager.processData(sessionId, data, buffer); }); } return sequence; } getBuffer(sessionId) { return this.buffers.get(sessionId) || ""; } getBufferWithSequence(sessionId) { const buffer = this.sequencedBuffers.get(sessionId); if (!buffer || buffer.entries.length === 0) { return { data: "", lastSequence: -1 }; } const data = buffer.entries.map((e) => e.data).join(""); const lastSequence = buffer.entries[buffer.entries.length - 1].sequence; return { data, lastSequence }; } getIncrementalData(sessionId, fromSequence) { const buffer = this.sequencedBuffers.get(sessionId); if (!buffer || buffer.entries.length === 0) { return { data: "", lastSequence: fromSequence }; } const newEntries = buffer.entries.filter((e) => e.sequence > fromSequence); if (newEntries.length === 0) { return { data: "", lastSequence: fromSequence }; } const data = newEntries.map((e) => e.data).join(""); const lastSequence = newEntries[newEntries.length - 1].sequence; return { data, lastSequence }; } clearBuffer(sessionId) { this.buffers.delete(sessionId); this.sequencedBuffers.delete(sessionId); } getAllSessions() { return Array.from(this.buffers.keys()); } }; // src/SessionStore.ts import fs2 from "fs/promises"; import path2 from "path"; var SessionStore = class { constructor(storePath = ".sessions") { this.initialized = false; this.initPromise = null; this.storePath = storePath; } async initialize() { if (this.initialized) return; if (this.initPromise) return this.initPromise; this.initPromise = this.ensureStoreExists(); await this.initPromise; this.initialized = true; } async ensureStoreExists() { try { await fs2.mkdir(this.storePath, { recursive: true }); } catch (error) { console.error("Error creating session store directory:", error); throw error; } } async saveSession(sessionId, session, buffer, cwd) { await this.initialize(); try { const sessionData = { session, buffer, cwd, env: { TERM: process.env.TERM || "xterm-256color", LANG: process.env.LANG || "en_US.UTF-8" } }; const filePath = path2.join(this.storePath, `${sessionId}.json`); await fs2.writeFile(filePath, JSON.stringify(sessionData, null, 2), "utf-8"); } catch (error) { console.error(`Error saving session ${sessionId}:`, error); } } async loadSession(sessionId) { await this.initialize(); try { const filePath = path2.join(this.storePath, `${sessionId}.json`); const data = await fs2.readFile(filePath, "utf-8"); return JSON.parse(data); } catch (error) { return null; } } async loadAllSessions() { await this.initialize(); const sessions = /* @__PURE__ */ new Map(); try { const files = await fs2.readdir(this.storePath); for (const file of files) { if (file.endsWith(".json")) { const sessionId = file.replace(".json", ""); const session = await this.loadSession(sessionId); if (session) { sessions.set(sessionId, session); } } } } catch (error) { console.error("Error loading sessions:", error); } return sessions; } async deleteSession(sessionId) { await this.initialize(); try { const filePath = path2.join(this.storePath, `${sessionId}.json`); await fs2.unlink(filePath); } catch (error) { } } async deleteAllSessions() { await this.initialize(); try { await fs2.rm(this.storePath, { recursive: true, force: true }); } catch (error) { } } async updateSessionBuffer(sessionId, buffer) { await this.initialize(); try { const filePath = path2.join(this.storePath, `${sessionId}.json`); const data = await fs2.readFile(filePath, "utf-8"); const storedSession = JSON.parse(data); if (storedSession.buffer !== buffer) { storedSession.buffer = buffer; await fs2.writeFile(filePath, JSON.stringify(storedSession, null, 2), "utf-8"); } } catch (error) { } } async saveSessionPatterns(sessionId, patterns) { await this.initialize(); try { const filePath = path2.join(this.storePath, `${sessionId}.json`); const data = await fs2.readFile(filePath, "utf-8"); const storedSession = JSON.parse(data); storedSession.patterns = patterns; await fs2.writeFile(filePath, JSON.stringify(storedSession, null, 2), "utf-8"); } catch (error) { console.error(`Error saving patterns for session ${sessionId}:`, error); } } async getSessionPatterns(sessionId) { await this.initialize(); try { const session = await this.loadSession(sessionId); return session?.patterns || []; } catch (error) { return []; } } }; // src/WebSocketServer.ts import { WebSocketServer as WSServer } from "ws"; // src/admin/AdminSessionProxy.ts import { EventEmitter as EventEmitter2 } from "events"; var AdminSessionProxy = class extends EventEmitter2 { constructor(sessionManager) { super(); this.sessionManager = sessionManager; this.attachedSessions = /* @__PURE__ */ new Map(); } async attachToSession(sessionId, mode = "read-only") { const session = this.sessionManager.getSession(sessionId); if (!session) { throw new Error(`Session ${sessionId} not found`); } this.attachedSessions.set(sessionId, { sessionId, mode, attachedAt: /* @__PURE__ */ new Date() }); this.emit("attached", { sessionId, mode }); } async detachFromSession(sessionId) { this.attachedSessions.delete(sessionId); this.emit("detached", { sessionId }); } async writeToSession(sessionId, data) { const handle = this.attachedSessions.get(sessionId); if (!handle || handle.mode !== "interactive") { throw new Error("Session not in interactive mode"); } this.sessionManager.writeToSession(sessionId, data); } getAttachedSessions() { return Array.from(this.attachedSessions.values()); } }; // src/WebSocketServer.ts var WebSocketServer = class _WebSocketServer { constructor(wss, sessionManager, bufferManager, eventManager, sessionStore) { this.clients = /* @__PURE__ */ new Map(); this.clientStates = /* @__PURE__ */ new Map(); this.clientPatterns = /* @__PURE__ */ new Map(); this.clientEventSubscriptions = /* @__PURE__ */ new Map(); this.monitorClients = /* @__PURE__ */ new Set(); this.adminClients = /* @__PURE__ */ new Map(); this.wss = wss; this.sessionManager = sessionManager; this.bufferManager = bufferManager; this.eventManager = eventManager; this.sessionStore = sessionStore; this.adminProxy = new AdminSessionProxy(this.sessionManager); if (this.eventManager) { this.setupEventSystem(); } this.setupWebSocketHandlers(); } static create(config, sessionManager, bufferManager, eventManager, sessionStore) { let wss; if (typeof config === "number") { wss = new WSServer({ port: config, host: "0.0.0.0" }); } else { const { server, port, noServer, path: path5, ...wsOptions } = config; if (server && path5) { wss = new WSServer({ noServer: true, ...wsOptions }); server.on("upgrade", function upgrade(request, socket, head) { socket.on("error", (err) => { console.error("Socket error:", err); }); const pathname = new URL(request.url || "", `http://${request.headers.host}`).pathname; if (pathname === path5) { wss.handleUpgrade(request, socket, head, function done(ws) { wss.emit("connection", ws, request); }); } else { socket.destroy(); } }); } else if (server) { wss = new WSServer({ server, ...wsOptions }); } else if (noServer) { wss = new WSServer({ noServer: true, ...wsOptions }); } else if (port !== void 0) { wss = new WSServer({ port, host: wsOptions.host || "0.0.0.0", ...wsOptions }); } else { throw new Error("WebSocketServer requires either port, server, or noServer option"); } } return new _WebSocketServer(wss, sessionManager, bufferManager, eventManager, sessionStore); } setupWebSocketHandlers() { this.wss.on("connection", (ws) => { const clientId = Math.random().toString(36).substring(7); this.clients.set(clientId, ws); this.clientStates.set(clientId, { clientId, sessionIds: /* @__PURE__ */ new Set(), lastReceivedSequence: /* @__PURE__ */ new Map(), connectionTime: Date.now(), isIncrementalClient: false }); ws.on("message", (message) => { try { const data = JSON.parse(message); this.handleMessage(clientId, ws, data); } catch (error) { ws.send(JSON.stringify({ type: "error", data: "Invalid message format" })); } }); ws.on("close", () => { const clientState = this.clientStates.get(clientId); if (clientState) { clientState.sessionIds.forEach((sessionId) => { this.sessionManager.removeClient(sessionId, clientId); }); } if (this.eventManager) { const patterns = this.clientPatterns.get(clientId); if (patterns) { for (const patternId of patterns) { this.eventManager.unregisterPattern(patternId).catch((err) => { }); } } } this.adminClients.forEach((adminSet, sessionId) => { adminSet.delete(ws); if (adminSet.size === 0) { this.adminClients.delete(sessionId); } }); this.clients.delete(clientId); this.clientStates.delete(clientId); this.clientPatterns.delete(clientId); this.clientEventSubscriptions.delete(clientId); this.monitorClients.delete(clientId); }); ws.on("error", (error) => { }); }); } handleMessage(clientId, ws, data) { if (data.type.startsWith("admin-")) { this.handleAdminMessage(clientId, ws, data); return; } const handlers = { "create": this.handleCreateSession.bind(this), "connect": this.handleConnectSession.bind(this), "input": this.handleSessionInput.bind(this), "resize": this.handleSessionResize.bind(this), "disconnect": this.handleSessionDisconnect.bind(this), "register-pattern": this.handleRegisterPattern.bind(this), "unregister-pattern": this.handleUnregisterPattern.bind(this), "subscribe-events": this.handleSubscribeEvents.bind(this), "unsubscribe-events": this.handleUnsubscribeEvents.bind(this), "monitor-all": this.handleMonitorAll.bind(this) }; const handler = handlers[data.type]; if (handler) { handler(clientId, ws, data); } else { ws.send(JSON.stringify({ type: "error", data: `Unknown message type: ${data.type}` })); } } handleCreateSession(clientId, ws, data) { try { const options = data.options || {}; if (data.cols) options.cols = data.cols; if (data.rows) options.rows = data.rows; const requestedSessionId = data.sessionId || data.options && data.options.id; if (requestedSessionId) { options.id = requestedSessionId; const existingSession = this.sessionManager.getSession(requestedSessionId); if (existingSession) { this.sessionManager.addClient(requestedSessionId, clientId); const clientState2 = this.clientStates.get(clientId); if (clientState2) { clientState2.sessionIds.add(requestedSessionId); } const response2 = { type: "created", sessionId: requestedSessionId, session: existingSession }; ws.send(JSON.stringify(response2)); return; } } const session = this.sessionManager.createSession(options); this.sessionManager.addClient(session.id, clientId); const clientState = this.clientStates.get(clientId); if (clientState) { clientState.sessionIds.add(session.id); } const response = { type: "created", sessionId: session.id, session }; ws.send(JSON.stringify(response)); } catch (error) { console.error("[WebSocketServer] Error creating session:", error); ws.send(JSON.stringify({ type: "error", data: error instanceof Error ? error.message : "Failed to create session", requestId: data.requestId })); } } handleConnectSession(clientId, ws, data) { if (!data.sessionId) { ws.send(JSON.stringify({ type: "error", data: "Session ID required" })); return; } const session = this.sessionManager.getSession(data.sessionId); if (session) { this.sessionManager.addClient(data.sessionId, clientId); const clientState = this.clientStates.get(clientId); if (!clientState) { ws.send(JSON.stringify({ type: "error", data: "Client state not found" })); return; } clientState.sessionIds.add(data.sessionId); const useIncremental = data.useIncrementalUpdates === true || data.incremental === true; clientState.isIncrementalClient = useIncremental; let response = { type: "connect", sessionId: data.sessionId, session }; if (useIncremental && data.lastSequence !== void 0) { const lastClientSequence = data.lastSequence; const { data: incrementalData, lastSequence } = this.bufferManager.getIncrementalData( data.sessionId, lastClientSequence ); if (incrementalData) { response.incrementalData = incrementalData; response.fromSequence = lastClientSequence; response.lastSequence = lastSequence; } else { response.lastSequence = lastClientSequence; } clientState.lastReceivedSequence.set(data.sessionId, lastSequence); } else { const { data: scrollback, lastSequence } = this.bufferManager.getBufferWithSequence(data.sessionId); response.scrollback = scrollback; response.lastSequence = lastSequence; if (useIncremental) { clientState.lastReceivedSequence.set(data.sessionId, lastSequence); } } ws.send(JSON.stringify(response)); } else { ws.send(JSON.stringify({ type: "error", data: "Session not found" })); } } handleSessionInput(clientId, ws, data) { if (!data.sessionId || !data.data) { ws.send(JSON.stringify({ type: "error", data: "Session ID and data required" })); return; } const success = this.sessionManager.writeToSession(data.sessionId, data.data); if (!success) { ws.send(JSON.stringify({ type: "error", data: "Failed to write to session - session may be disconnected", sessionId: data.sessionId })); } } handleSessionResize(clientId, ws, data) { if (!data.sessionId || !data.cols || !data.rows) { ws.send(JSON.stringify({ type: "error", data: "Session ID, cols, and rows required" })); return; } this.sessionManager.resizeSession(data.sessionId, data.cols, data.rows); this.broadcastToSession(data.sessionId, { type: "resize", sessionId: data.sessionId, cols: data.cols, rows: data.rows }); } handleSessionDisconnect(clientId, ws, data) { if (data.sessionId) { this.sessionManager.removeClient(data.sessionId, clientId); const clientState = this.clientStates.get(clientId); if (clientState) { clientState.sessionIds.delete(data.sessionId); clientState.lastReceivedSequence.delete(data.sessionId); } } } async handleAdminMessage(clientId, ws, message) { try { switch (message.type) { case "admin-list-sessions": const sessions = this.sessionManager.getAllSessionMetadata(); ws.send(JSON.stringify({ type: "admin-sessions-list", sessions })); break; case "admin-attach": if (!message.sessionId) return; await this.adminProxy.attachToSession(message.sessionId, message.mode); if (!this.adminClients.has(message.sessionId)) { this.adminClients.set(message.sessionId, /* @__PURE__ */ new Set()); } this.adminClients.get(message.sessionId).add(ws); const buffer = this.bufferManager.getBuffer(message.sessionId); ws.send(JSON.stringify({ type: "buffer", sessionId: message.sessionId, data: buffer })); break; case "admin-detach": if (!message.sessionId) return; await this.adminProxy.detachFromSession(message.sessionId); this.adminClients.get(message.sessionId)?.delete(ws); break; case "admin-input": if (!message.sessionId || !message.data) return; await this.adminProxy.writeToSession(message.sessionId, message.data); break; } } catch (error) { ws.send(JSON.stringify({ type: "error", message: error.message })); } } broadcastToSession(sessionId, data) { this.clients.forEach((ws, clientId) => { const clientState = this.clientStates.get(clientId); if (clientState && clientState.sessionIds.has(sessionId) && ws.readyState === ws.OPEN) { if (data.type === "output" && data.sequence !== void 0) { if (clientState.isIncrementalClient) { clientState.lastReceivedSequence.set(sessionId, data.sequence); } } ws.send(JSON.stringify(data)); } }); if (data.type === "output" && this.monitorClients.size > 0) { const monitorMessage = { type: "session-output", sessionId, data: data.data, timestamp: (/* @__PURE__ */ new Date()).toISOString() }; this.monitorClients.forEach((monitorId) => { const ws = this.clients.get(monitorId); if (ws && ws.readyState === ws.OPEN) { ws.send(JSON.stringify(monitorMessage)); } }); } const adminViewers = this.adminClients.get(sessionId); if (adminViewers && adminViewers.size > 0) { const adminData = JSON.stringify(data); adminViewers.forEach((ws) => { if (ws.readyState === ws.OPEN) { ws.send(adminData); } }); } } setupEventSystem() { if (!this.eventManager) return; this.eventManager.on("terminal-event", (event) => { const message = { type: "terminal-event", event }; this.clients.forEach((ws, clientId) => { const subscriptions = this.clientEventSubscriptions.get(clientId); if (subscriptions && subscriptions.has(event.type) && ws.readyState === ws.OPEN) { ws.send(JSON.stringify(message)); } }); }); } async handleRegisterPattern(clientId, ws, message) { if (!this.eventManager) { ws.send(JSON.stringify({ type: "error", data: "Event system not enabled", requestId: message.requestId })); return; } try { const patternId = await this.eventManager.registerPattern(message.sessionId, message.config); if (!this.clientPatterns.has(clientId)) { this.clientPatterns.set(clientId, /* @__PURE__ */ new Set()); } this.clientPatterns.get(clientId).add(patternId); ws.send(JSON.stringify({ type: "pattern-registered", patternId, requestId: message.requestId })); } catch (error) { ws.send(JSON.stringify({ type: "error", data: error instanceof Error ? error.message : "Unknown error", requestId: message.requestId })); } } async handleUnregisterPattern(clientId, ws, message) { if (!this.eventManager) { ws.send(JSON.stringify({ type: "error", data: "Event system not enabled", requestId: message.requestId })); return; } try { await this.eventManager.unregisterPattern(message.patternId); const patterns = this.clientPatterns.get(clientId); if (patterns) { patterns.delete(message.patternId); } ws.send(JSON.stringify({ type: "pattern-unregistered", patternId: message.patternId, requestId: message.requestId })); } catch (error) { ws.send(JSON.stringify({ type: "error", data: error instanceof Error ? error.message : "Unknown error", requestId: message.requestId })); } } handleSubscribeEvents(clientId, ws, message) { if (!this.clientEventSubscriptions.has(clientId)) { this.clientEventSubscriptions.set(clientId, /* @__PURE__ */ new Set()); } const subscriptions = this.clientEventSubscriptions.get(clientId); for (const eventType of message.eventTypes) { subscriptions.add(eventType); } ws.send(JSON.stringify({ type: "subscribed", eventTypes: message.eventTypes })); } handleUnsubscribeEvents(clientId, ws, message) { const subscriptions = this.clientEventSubscriptions.get(clientId); if (subscriptions) { for (const eventType of message.eventTypes) { subscriptions.delete(eventType); } } ws.send(JSON.stringify({ type: "unsubscribed", eventTypes: message.eventTypes })); } handleMonitorAll(clientId, ws, data) { const authKey = data.authKey || data.auth; const expectedAuthKey = process.env.SHELLTENDER_MONITOR_AUTH_KEY || "default-monitor-key"; if (authKey !== expectedAuthKey) { ws.send(JSON.stringify({ type: "error", data: "Invalid authentication key for monitor mode", requestId: data.requestId })); return; } this.monitorClients.add(clientId); ws.isMonitor = true; ws.send(JSON.stringify({ type: "monitor-mode-enabled", message: "Successfully enabled monitor mode. You will receive all terminal output.", sessionCount: this.sessionManager.getAllSessions().length })); } // Get number of clients connected to a specific session getSessionClientCount(sessionId) { let count = 0; this.clients.forEach((ws, clientId) => { const clientState = this.clientStates.get(clientId); if (clientState && clientState.sessionIds.has(sessionId) && ws.readyState === ws.OPEN) { count++; } }); return count; } // Get all client connections grouped by session getClientsBySession() { const sessionClients = /* @__PURE__ */ new Map(); this.clients.forEach((ws, clientId) => { const clientState = this.clientStates.get(clientId); if (clientState && ws.readyState === ws.OPEN) { clientState.sessionIds.forEach((sessionId) => { sessionClients.set(sessionId, (sessionClients.get(sessionId) || 0) + 1); }); } }); return sessionClients; } }; // src/events/EventManager.ts import { EventEmitter as EventEmitter3 } from "events"; // src/patterns/PatternMatcher.ts var PatternMatcher = class { constructor(config, id) { this.config = config; this.id = id; this.lastMatchTime = 0; this.matchCount = 0; } /** * Get the pattern ID */ getId() { return this.id; } /** * Get the pattern name */ getName() { return this.config.name; } /** * Get the pattern configuration */ getConfig() { return this.config; } /** * Wrapper method that handles debouncing and performance tracking */ tryMatch(data, buffer) { const now = Date.now(); if (this.config.options?.debounce) { if (now - this.lastMatchTime < this.config.options.debounce) { return null; } } const result = this.measureMatch(() => this.match(data, buffer)); if (result) { this.lastMatchTime = now; this.matchCount++; } return result; } /** * Helper for performance tracking */ measureMatch(fn) { const start = performance.now(); const result = fn(); const duration = performance.now() - start; if (duration > 10) { console.warn( `[PatternMatcher] Slow match detected: ${this.config.name} took ${duration.toFixed(2)}ms` ); } return result; } /** * Get matcher statistics */ getStats() { return { id: this.id, name: this.config.name, type: this.config.type, matchCount: this.matchCount, lastMatchTime: this.lastMatchTime }; } /** * Validate the pattern configuration * Can be overridden by subclasses for specific validation */ validate() { if (!this.config.name) { throw new Error("Pattern name is required"); } if (!this.config.type) { throw new Error("Pattern type is required"); } } }; // src/patterns/RegexMatcher.ts var RegexMatcher = class extends PatternMatcher { constructor(config, id) { super(config, id); this.regex = this.createRegex(); } /** * Create the RegExp instance from the pattern configuration */ createRegex() { if (this.config.pattern instanceof RegExp) { return this.config.pattern; } if (typeof this.config.pattern === "string") { const flags = this.buildFlags(); return new RegExp(this.config.pattern, flags); } throw new Error("RegexMatcher requires a string or RegExp pattern"); } /** * Build regex flags from configuration options */ buildFlags() { let flags = ""; flags += "g"; if (this.config.options?.caseSensitive === false) { flags += "i"; } if (this.config.options?.multiline) { flags += "m"; } return flags; } /** * Perform the regex match */ match(data, buffer) { this.regex.lastIndex = 0; const match = this.regex.exec(data); if (match) { return { match: match[0], position: match.index, groups: this.extractGroups(match) }; } if (this.config.options?.multiline && buffer !== data) { this.regex.lastIndex = 0; const bufferMatch = this.regex.exec(buffer); if (bufferMatch) { return { match: bufferMatch[0], position: bufferMatch.index, groups: this.extractGroups(bufferMatch) }; } } return null; } /** * Extract named and numbered capture groups */ extractGroups(match) { if (match.length <= 1) { return void 0; } const groups = {}; for (let i = 1; i < match.length; i++) { if (match[i] !== void 0) { groups[i.toString()] = match[i]; } } if (match.groups) { Object.assign(groups, match.groups); } return Object.keys(groups).length > 0 ? groups : void 0; } /** * Validate the regex pattern */ validate() { super.validate(); try { this.regex.test(""); } catch (error) { throw new Error(`Invalid regex pattern: ${error instanceof Error ? error.message : "Unknown error"}`); } } }; // src/patterns/StringMatcher.ts var StringMatcher = class extends PatternMatcher { constructor(config, id) { super(config, id); if (typeof this.config.pattern !== "string") { throw new Error("StringMatcher requires a string pattern"); } this.searchString = this.config.pattern; this.caseSensitive = this.config.options?.caseSensitive ?? true; if (!this.caseSensitive) { this.searchString = this.searchString.toLowerCase(); } } /** * Perform the string match */ match(data, buffer) { const searchIn = this.caseSensitive ? data : data.toLowerCase(); const index = searchIn.indexOf(this.searchString); if (index !== -1) { const match = data.substring(index, index + this.searchString.length); return { match, position: index }; } return null; } /** * Validate the string pattern */ validate() { super.validate(); if (!this.searchString || this.searchString.length === 0) { throw new Error("String pattern cannot be empty"); } } }; // src/patterns/AnsiMatcher.ts var _AnsiMatcher = class _AnsiMatcher extends PatternMatcher { constructor(config, id) { super(config, id); this.category = null; if (typeof this.config.pattern === "string") { this.pattern = this.getPatternByName(this.config.pattern) || new RegExp(this.config.pattern, "g"); this.category = this.config.pattern; } else if (this.config.pattern instanceof RegExp) { this.pattern = new RegExp(this.config.pattern.source, "g"); } else { this.pattern = _AnsiMatcher.ANSI_PATTERNS.any; this.category = "any"; } } /** * Get predefined pattern by name */ getPatternByName(name) { switch (name) { case "csi": case "cursor": case "color": return _AnsiMatcher.ANSI_PATTERNS.csi; case "osc": case "title": return _AnsiMatcher.ANSI_PATTERNS.osc; case "esc": return _AnsiMatcher.ANSI_PATTERNS.esc; case "any": case "all": return _AnsiMatcher.ANSI_PATTERNS.any; default: return null; } } /** * Perform the ANSI sequence match */ match(data, buffer) { this.pattern.lastIndex = 0; const match = this.pattern.exec(data); if (match) { const result = { match: match[0], position: match.index }; const parsed = this.parseAnsiSequence(match); if (parsed) { result.groups = { type: parsed.type, ...parsed.data }; } return result; } return null; } /** * Parse ANSI sequence into structured data */ parseAnsiSequence(match) { const fullMatch = match[0]; if (fullMatch.startsWith("\x1B[")) { const params = match[1] || ""; const command = match[2] || ""; return { type: this.categorizeCsiCommand(command), data: { command, params: params ? params.split(";").map((p) => parseInt(p, 10) || 0) : [], raw: fullMatch } }; } if (fullMatch.startsWith("\x1B]")) { const oscMatch = fullMatch.match(/\x1b\](\d+);([^\x07]*)\x07/); const code = oscMatch ? oscMatch[1] : ""; const data = oscMatch ? oscMatch[2] : ""; return { type: "osc", data: { code: parseInt(code, 10) || 0, data: data || "", raw: fullMatch } }; } if (fullMatch.startsWith("\x1B")) { const command = match[5] || fullMatch[1]; return { type: "esc", data: { command, raw: fullMatch } }; } return null; } /** * Categorize CSI commands */ categorizeCsiCommand(command) { switch (command) { // Cursor movement case "A": // Up case "B": // Down case "C": // Forward case "D": // Back case "H": // Position case "f": return "cursor"; // Colors and styling case "m": return "color"; // Screen/line clearing case "J": // Clear screen case "K": return "clear"; // Others default: return "other"; } } /** * Validate the ANSI pattern */ validate() { super.validate(); } }; // Common ANSI escape sequence patterns _AnsiMatcher.ANSI_PATTERNS = { // CSI sequences (most common - cursor, color, etc.) csi: /\x1b\[([0-9;]*)([@-~])/g, // OSC sequences (window title, etc.) osc: /\x1b\]([0-9]+);([^\x07\x1b]*)\x07/g, // Simple ESC sequences esc: /\x1b([A-Z\\^_@\[\]])/g, // Any ANSI sequence (catch-all) any: /\x1b(?:\[([0-9;]*)([@-~])|\]([0-9]+);([^\x07\x1b]*)\x07|([A-Z\\^_@\[\]]))/g }; var AnsiMatcher = _AnsiMatcher; // src/patterns/CustomMatcher.ts var CustomMatcher = class extends PatternMatcher { constructor(config, id) { super(config, id); if (typeof this.config.pattern !== "function") { throw new Error("CustomMatcher requires a function pattern"); } this.matcherFn = this.config.pattern; } /** * Perform the custom match */ match(data, buffer) { try { return this.matcherFn(data, buffer); } catch (error) { console.error(`[CustomMatcher] Error in custom matcher ${this.config.name}:`, error); return null; } } /** * Validate the custom matcher */ validate() { super.validate(); try { this.matcherFn("", ""); } catch (error) { throw new Error(`Invalid custom matcher: ${error instanceof Error ? error.message : "Unknown error"}`); } } }; // src/patterns/PatternMatcherFactory.ts var PatternMatcherFactory = class { /** * Create a pattern matcher instance */ static create(config, id) { let matcher; switch (config.type) { case "regex": matcher = new RegexMatcher(config, id); break; case "string": matcher = new StringMatcher(config, id); break; case "ansi": matcher = new AnsiMatcher(config, id); break; case "custom": matcher = new CustomMatcher(config, id); break; default: throw new Error(`Unknown pattern type: ${config.type}`); } matcher.validate(); return matcher; } /** * Validate a pattern configuration without creating a matcher */ static validate(config) { const tempId = "validation-" + Date.now(); const matcher = this.create(config, tempId); matcher.validate(); } }; // src/patterns/CommonPatterns.ts var CommonPatterns = { // Build Tools build: { npmInstall: { name: "npm-install", type: "regex", pattern: /npm\s+(install|i|add|remove|uninstall|rm)/i, description: "NPM package management commands", options: { debounce: 100 } }, npmScript: { name: "npm-script", type: "regex", pattern: />\s*(\S+@\S+)\s+(\w+)/, description: "NPM script execution", options: { debounce: 100 } }, buildSuccess: { name: "build-success", type: "regex", pattern: /(?:build|built|compiled?)\s+(?:successfully|succeeded|completed?)(?:\s+in\s+(\d+(?:\.\d+)?)\s*(?:ms|s|seconds?|minutes?))?/i, description: "Successful build completion", options: { debounce: 500 } }, buildFailure: { name: "build-failure", type: "regex", pattern: /(?:build|compilation)\s+(?:failed|error)/i, description: "Build failures", options: { debounce: 500 } }, webpack: { name: "webpack-status", type: "regex", pattern: /webpack\s+(\d+\.\d+\.\d+)\s+compiled\s+(?:successfully|with\s+\d+\s+warnings?)/, description: "Webpack compilation status", options: { debounce: 200 } } }, // Version Control git: { command: { name: "git-command", type: "regex", pattern: /git\s+(status|add|commit|push|pull|fetch|merge|checkout|branch|log|diff|stash|reset|rebase)/i, description: "Git commands", options: { debounce: 100 } }, branch: { name: "git-branch", type: "regex", pattern: /(?:On branch|HEAD detached at|Switched to branch)\s+([^\s]+)/, description: "Git branch information", options: { debounce: 200 } }, remote: { name: "git-remote", type: "regex", pattern: /(?:origin|upstream)\s+(\S+)\s+\((?:fetch|push)\)/, description: "Git remote URLs", options: { debounce: 200 } }, conflict: { name: "git-conflict", type: "regex", pattern: /CONFLICT\s+\([^)]+\):\s+(.+)/, description: "Git merge conflicts", options: { debounce: 100 } } }, // Testing testing: { jest: { name: "jest-results", type: "regex", pattern: /Tests?:\s+(\d+)\s+passed,\s+(\d+)\s+failed(?:,\s+(\d+)\s+skipped)?/, des