1413 lines · 46.2 KB · TypeScript
RawDownload
import { EventEmitter } from "node:events";import fs from "node:fs";import path from "node:path"; import {  DEFAULT_TERMINAL_ID,  TerminalClearInput,  TerminalCloseInput,  TerminalOpenInput,  TerminalResizeInput,  TerminalRestartInput,  TerminalWriteInput,  type TerminalEvent,  type TerminalSessionSnapshot,} from "@t3tools/contracts";import { Effect, Encoding, Layer, Schema } from "effect"; import { createLogger } from "../../logger";import { PtyAdapter, PtyAdapterShape, type PtyExitEvent, type PtyProcess } from "../Services/PTY";import { runProcess } from "../../processRunner";import { ServerConfig } from "../../config";import {  ShellCandidate,  TerminalError,  TerminalManager,  TerminalManagerShape,  TerminalSessionState,  TerminalStartInput,} from "../Services/Manager"; const DEFAULT_HISTORY_LINE_LIMIT = 5_000;const DEFAULT_PERSIST_DEBOUNCE_MS = 40;const DEFAULT_SUBPROCESS_POLL_INTERVAL_MS = 1_000;const DEFAULT_PROCESS_KILL_GRACE_MS = 1_000;const DEFAULT_MAX_RETAINED_INACTIVE_SESSIONS = 128;const DEFAULT_OPEN_COLS = 120;const DEFAULT_OPEN_ROWS = 30;const TERMINAL_ENV_BLOCKLIST = new Set(["PORT", "ELECTRON_RENDERER_PORT", "ELECTRON_RUN_AS_NODE"]); const decodeTerminalOpenInput = Schema.decodeUnknownSync(TerminalOpenInput);const decodeTerminalRestartInput = Schema.decodeUnknownSync(TerminalRestartInput);const decodeTerminalWriteInput = Schema.decodeUnknownSync(TerminalWriteInput);const decodeTerminalResizeInput = Schema.decodeUnknownSync(TerminalResizeInput);const decodeTerminalClearInput = Schema.decodeUnknownSync(TerminalClearInput);const decodeTerminalCloseInput = Schema.decodeUnknownSync(TerminalCloseInput); type TerminalSubprocessChecker = (terminalPid: number) => Promise<boolean>; function defaultShellResolver(): string {  if (process.platform === "win32") {    return process.env.ComSpec ?? "cmd.exe";  }  return process.env.SHELL ?? "bash";} function normalizeShellCommand(value: string | undefined): string | null {  if (!value) return null;  const trimmed = value.trim();  if (trimmed.length === 0) return null;   if (process.platform === "win32") {    return trimmed;  }   const firstToken = trimmed.split(/\s+/g)[0]?.trim();  if (!firstToken) return null;  return firstToken.replace(/^['"]|['"]$/g, "");} function shellCandidateFromCommand(command: string | null): ShellCandidate | null {  if (!command || command.length === 0) return null;  const shellName = path.basename(command).toLowerCase();  if (process.platform !== "win32" && shellName === "zsh") {    return { shell: command, args: ["-o", "nopromptsp"] };  }  return { shell: command };} function formatShellCandidate(candidate: ShellCandidate): string {  if (!candidate.args || candidate.args.length === 0) return candidate.shell;  return `${candidate.shell} ${candidate.args.join(" ")}`;} function uniqueShellCandidates(candidates: Array<ShellCandidate | null>): ShellCandidate[] {  const seen = new Set<string>();  const ordered: ShellCandidate[] = [];  for (const candidate of candidates) {    if (!candidate) continue;    const key = formatShellCandidate(candidate);    if (seen.has(key)) continue;    seen.add(key);    ordered.push(candidate);  }  return ordered;} function resolveShellCandidates(shellResolver: () => string): ShellCandidate[] {  const requested = shellCandidateFromCommand(normalizeShellCommand(shellResolver()));   if (process.platform === "win32") {    return uniqueShellCandidates([      requested,      shellCandidateFromCommand(process.env.ComSpec ?? null),      shellCandidateFromCommand("powershell.exe"),      shellCandidateFromCommand("cmd.exe"),    ]);  }   return uniqueShellCandidates([    requested,    shellCandidateFromCommand(normalizeShellCommand(process.env.SHELL)),    shellCandidateFromCommand("/bin/zsh"),    shellCandidateFromCommand("/bin/bash"),    shellCandidateFromCommand("/bin/sh"),    shellCandidateFromCommand("zsh"),    shellCandidateFromCommand("bash"),    shellCandidateFromCommand("sh"),  ]);} function isRetryableShellSpawnError(error: unknown): boolean {  const queue: unknown[] = [error];  const seen = new Set<unknown>();  const messages: string[] = [];   while (queue.length > 0) {    const current = queue.shift();    if (!current || seen.has(current)) {      continue;    }    seen.add(current);     if (typeof current === "string") {      messages.push(current);      continue;    }     if (current instanceof Error) {      messages.push(current.message);      const cause = (current as { cause?: unknown }).cause;      if (cause) {        queue.push(cause);      }      continue;    }     if (typeof current === "object") {      const value = current as { message?: unknown; cause?: unknown };      if (typeof value.message === "string") {        messages.push(value.message);      }      if (value.cause) {        queue.push(value.cause);      }    }  }   const message = messages.join(" ").toLowerCase();  return (    message.includes("posix_spawnp failed") ||    message.includes("enoent") ||    message.includes("not found") ||    message.includes("file not found") ||    message.includes("no such file")  );} async function checkWindowsSubprocessActivity(terminalPid: number): Promise<boolean> {  const command = [    `$children = Get-CimInstance Win32_Process -Filter "ParentProcessId = ${terminalPid}" -ErrorAction SilentlyContinue`,    "if ($children) { exit 0 }",    "exit 1",  ].join("; ");  try {    const result = await runProcess(      "powershell.exe",      ["-NoProfile", "-NonInteractive", "-Command", command],      {        timeoutMs: 1_500,        allowNonZeroExit: true,        maxBufferBytes: 32_768,        outputMode: "truncate",      },    );    return result.code === 0;  } catch {    return false;  }} async function checkPosixSubprocessActivity(terminalPid: number): Promise<boolean> {  try {    const pgrepResult = await runProcess("pgrep", ["-P", String(terminalPid)], {      timeoutMs: 1_000,      allowNonZeroExit: true,      maxBufferBytes: 32_768,      outputMode: "truncate",    });    if (pgrepResult.code === 0) {      return pgrepResult.stdout.trim().length > 0;    }    if (pgrepResult.code === 1) {      return false;    }  } catch {    // Fall back to ps when pgrep is unavailable.  }   try {    const psResult = await runProcess("ps", ["-eo", "pid=,ppid="], {      timeoutMs: 1_000,      allowNonZeroExit: true,      maxBufferBytes: 262_144,      outputMode: "truncate",    });    if (psResult.code !== 0) {      return false;    }     for (const line of psResult.stdout.split(/\r?\n/g)) {      const [pidRaw, ppidRaw] = line.trim().split(/\s+/g);      const pid = Number(pidRaw);      const ppid = Number(ppidRaw);      if (!Number.isInteger(pid) || !Number.isInteger(ppid)) continue;      if (ppid === terminalPid) {        return true;      }    }    return false;  } catch {    return false;  }} async function defaultSubprocessChecker(terminalPid: number): Promise<boolean> {  if (!Number.isInteger(terminalPid) || terminalPid <= 0) {    return false;  }  if (process.platform === "win32") {    return checkWindowsSubprocessActivity(terminalPid);  }  return checkPosixSubprocessActivity(terminalPid);} function capHistory(history: string, maxLines: number): string {  if (history.length === 0) return history;  const hasTrailingNewline = history.endsWith("\n");  const lines = history.split("\n");  if (hasTrailingNewline) {    lines.pop();  }  if (lines.length <= maxLines) return history;  const capped = lines.slice(lines.length - maxLines).join("\n");  return hasTrailingNewline ? `${capped}\n` : capped;} function isCsiFinalByte(codePoint: number): boolean {  return codePoint >= 0x40 && codePoint <= 0x7e;} function shouldStripCsiSequence(body: string, finalByte: string): boolean {  if (finalByte === "n") {    return true;  }  if (finalByte === "R" && /^[0-9;?]*$/.test(body)) {    return true;  }  if (finalByte === "c" && /^[>0-9;?]*$/.test(body)) {    return true;  }  return false;} function shouldStripOscSequence(content: string): boolean {  return /^(10|11|12);(?:\?|rgb:)/.test(content);} function stripStringTerminator(value: string): string {  if (value.endsWith("\u001b\\")) {    return value.slice(0, -2);  }  const lastCharacter = value.at(-1);  if (lastCharacter === "\u0007" || lastCharacter === "\u009c") {    return value.slice(0, -1);  }  return value;} function findStringTerminatorIndex(input: string, start: number): number | null {  for (let index = start; index < input.length; index += 1) {    const codePoint = input.charCodeAt(index);    if (codePoint === 0x07 || codePoint === 0x9c) {      return index + 1;    }    if (codePoint === 0x1b && input.charCodeAt(index + 1) === 0x5c) {      return index + 2;    }  }  return null;} function isEscapeIntermediateByte(codePoint: number): boolean {  return codePoint >= 0x20 && codePoint <= 0x2f;} function isEscapeFinalByte(codePoint: number): boolean {  return codePoint >= 0x30 && codePoint <= 0x7e;} function findEscapeSequenceEndIndex(input: string, start: number): number | null {  let cursor = start;  while (cursor < input.length && isEscapeIntermediateByte(input.charCodeAt(cursor))) {    cursor += 1;  }  if (cursor >= input.length) {    return null;  }  return isEscapeFinalByte(input.charCodeAt(cursor)) ? cursor + 1 : start + 1;} function sanitizeTerminalHistoryChunk(  pendingControlSequence: string,  data: string,): { visibleText: string; pendingControlSequence: string } {  const input = `${pendingControlSequence}${data}`;  let visibleText = "";  let index = 0;   const append = (value: string) => {    visibleText += value;  };   while (index < input.length) {    const codePoint = input.charCodeAt(index);     if (codePoint === 0x1b) {      const nextCodePoint = input.charCodeAt(index + 1);      if (Number.isNaN(nextCodePoint)) {        return { visibleText, pendingControlSequence: input.slice(index) };      }       if (nextCodePoint === 0x5b) {        let cursor = index + 2;        while (cursor < input.length) {          if (isCsiFinalByte(input.charCodeAt(cursor))) {            const sequence = input.slice(index, cursor + 1);            const body = input.slice(index + 2, cursor);            if (!shouldStripCsiSequence(body, input[cursor] ?? "")) {              append(sequence);            }            index = cursor + 1;            break;          }          cursor += 1;        }        if (cursor >= input.length) {          return { visibleText, pendingControlSequence: input.slice(index) };        }        continue;      }       if (        nextCodePoint === 0x5d ||        nextCodePoint === 0x50 ||        nextCodePoint === 0x5e ||        nextCodePoint === 0x5f      ) {        const terminatorIndex = findStringTerminatorIndex(input, index + 2);        if (terminatorIndex === null) {          return { visibleText, pendingControlSequence: input.slice(index) };        }        const sequence = input.slice(index, terminatorIndex);        const content = stripStringTerminator(input.slice(index + 2, terminatorIndex));        if (nextCodePoint !== 0x5d || !shouldStripOscSequence(content)) {          append(sequence);        }        index = terminatorIndex;        continue;      }       const escapeSequenceEndIndex = findEscapeSequenceEndIndex(input, index + 1);      if (escapeSequenceEndIndex === null) {        return { visibleText, pendingControlSequence: input.slice(index) };      }      append(input.slice(index, escapeSequenceEndIndex));      index = escapeSequenceEndIndex;      continue;    }     if (codePoint === 0x9b) {      let cursor = index + 1;      while (cursor < input.length) {        if (isCsiFinalByte(input.charCodeAt(cursor))) {          const sequence = input.slice(index, cursor + 1);          const body = input.slice(index + 1, cursor);          if (!shouldStripCsiSequence(body, input[cursor] ?? "")) {            append(sequence);          }          index = cursor + 1;          break;        }        cursor += 1;      }      if (cursor >= input.length) {        return { visibleText, pendingControlSequence: input.slice(index) };      }      continue;    }     if (codePoint === 0x9d || codePoint === 0x90 || codePoint === 0x9e || codePoint === 0x9f) {      const terminatorIndex = findStringTerminatorIndex(input, index + 1);      if (terminatorIndex === null) {        return { visibleText, pendingControlSequence: input.slice(index) };      }      const sequence = input.slice(index, terminatorIndex);      const content = stripStringTerminator(input.slice(index + 1, terminatorIndex));      if (codePoint !== 0x9d || !shouldStripOscSequence(content)) {        append(sequence);      }      index = terminatorIndex;      continue;    }     append(input[index] ?? "");    index += 1;  }   return { visibleText, pendingControlSequence: "" };} function legacySafeThreadId(threadId: string): string {  return threadId.replace(/[^a-zA-Z0-9._-]/g, "_");} function toSafeThreadId(threadId: string): string {  return `terminal_${Encoding.encodeBase64Url(threadId)}`;} function toSafeTerminalId(terminalId: string): string {  return Encoding.encodeBase64Url(terminalId);} function toSessionKey(threadId: string, terminalId: string): string {  return `${threadId}\u0000${terminalId}`;} function shouldExcludeTerminalEnvKey(key: string): boolean {  const normalizedKey = key.toUpperCase();  if (normalizedKey.startsWith("T3CODE_")) {    return true;  }  if (normalizedKey.startsWith("VITE_")) {    return true;  }  return TERMINAL_ENV_BLOCKLIST.has(normalizedKey);} function createTerminalSpawnEnv(  baseEnv: NodeJS.ProcessEnv,  runtimeEnv?: Record<string, string> | null,): NodeJS.ProcessEnv {  const spawnEnv: NodeJS.ProcessEnv = {};  for (const [key, value] of Object.entries(baseEnv)) {    if (value === undefined) continue;    if (shouldExcludeTerminalEnvKey(key)) continue;    spawnEnv[key] = value;  }  if (runtimeEnv) {    for (const [key, value] of Object.entries(runtimeEnv)) {      spawnEnv[key] = value;    }  }  return spawnEnv;} function normalizedRuntimeEnv(  env: Record<string, string> | undefined,): Record<string, string> | null {  if (!env) return null;  const entries = Object.entries(env);  if (entries.length === 0) return null;  return Object.fromEntries(entries.toSorted(([left], [right]) => left.localeCompare(right)));} interface TerminalManagerEvents {  event: [event: TerminalEvent];} interface TerminalManagerOptions {  logsDir?: string;  historyLineLimit?: number;  ptyAdapter: PtyAdapterShape;  shellResolver?: () => string;  subprocessChecker?: TerminalSubprocessChecker;  subprocessPollIntervalMs?: number;  processKillGraceMs?: number;  maxRetainedInactiveSessions?: number;} export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents> {

Lines 1–500 of 1413.