From 046f0dbe9205d86b34658cfabd8166ffaa38a69e Mon Sep 17 00:00:00 2001 From: Sergey Kozyrenko Date: Thu, 9 Apr 2026 01:36:20 +0700 Subject: [PATCH 1/2] feat: update terminal --- frontend/src/components/shared/terminal.tsx | 786 ------------------ .../src/components/shared/terminal/index.ts | 2 + .../shared/terminal/terminal-config.ts | 100 +++ .../terminal/terminal-sanitizer.test.ts | 622 ++++++++++++++ .../shared/terminal/terminal-sanitizer.ts | 395 +++++++++ .../components/shared/terminal/terminal.tsx | 92 ++ .../shared/terminal/use-terminal-search.ts | 76 ++ .../components/shared/terminal/use-xterm.ts | 328 ++++++++ 8 files changed, 1615 insertions(+), 786 deletions(-) delete mode 100644 frontend/src/components/shared/terminal.tsx create mode 100644 frontend/src/components/shared/terminal/index.ts create mode 100644 frontend/src/components/shared/terminal/terminal-config.ts create mode 100644 frontend/src/components/shared/terminal/terminal-sanitizer.test.ts create mode 100644 frontend/src/components/shared/terminal/terminal-sanitizer.ts create mode 100644 frontend/src/components/shared/terminal/terminal.tsx create mode 100644 frontend/src/components/shared/terminal/use-terminal-search.ts create mode 100644 frontend/src/components/shared/terminal/use-xterm.ts diff --git a/frontend/src/components/shared/terminal.tsx b/frontend/src/components/shared/terminal.tsx deleted file mode 100644 index a6911eea..00000000 --- a/frontend/src/components/shared/terminal.tsx +++ /dev/null @@ -1,786 +0,0 @@ -import '@xterm/xterm/css/xterm.css'; - -import type { ITerminalOptions, ITheme } from '@xterm/xterm'; - -import { FitAddon } from '@xterm/addon-fit'; -import { SearchAddon } from '@xterm/addon-search'; -import { Unicode11Addon } from '@xterm/addon-unicode11'; -import { WebLinksAddon } from '@xterm/addon-web-links'; -import { WebglAddon } from '@xterm/addon-webgl'; -import { Terminal as XTerminal } from '@xterm/xterm'; -import debounce from 'lodash/debounce'; -import { useCallback, useEffect, useImperativeHandle, useReducer, useRef } from 'react'; - -import { useTheme } from '@/hooks/use-theme'; -import { Log } from '@/lib/log'; -import { cn } from '@/lib/utils'; - -/** - * Sanitizes terminal output by handling binary/non-printable characters. - * Preserves ANSI escape sequences for colors and formatting. - * Replaces all non-ASCII characters with dots to prevent xterm.js parser errors. - * - * This aggressive approach is necessary because binary data (like JPEG files) - * gets interpreted as UTF-8 by JavaScript, creating "fake" Unicode characters - * that cause xterm.js parser to fail. - * - * @param input - The raw string that may contain binary or non-printable characters - * @returns Sanitized string safe for terminal display - */ -const sanitizeTerminalOutput = (input: string): string => { - if (!input) { - return input; - } - - const result: string[] = []; - let index = 0; - - while (index < input.length) { - const charCode = input.charCodeAt(index); - - // Check for ANSI escape sequence (ESC [ ... or ESC followed by other sequences) - if (charCode === 0x1b) { - // ESC character - const escapeStart = index; - index++; - - if (index < input.length) { - const nextChar = input.charAt(index); - const nextCharCode = input.charCodeAt(index); - - // CSI sequence: ESC [ - if (nextChar === '[') { - index++; - - // Read until we find the final byte (0x40-0x7E) or hit a problematic char - let validSequence = true; - - while (index < input.length) { - const seqChar = input.charCodeAt(index); - - // Only allow ASCII characters within CSI sequence - if (seqChar > 0x7e || seqChar < 0x20) { - validSequence = false; - - break; - } - - index++; - - // Final byte of CSI sequence (letters and some symbols) - if (seqChar >= 0x40 && seqChar <= 0x7e) { - break; - } - } - - if (validSequence) { - result.push(input.slice(escapeStart, index)); - } else { - // Invalid sequence - replace ESC with dot and continue from next char - result.push('.'); - index = escapeStart + 1; - } - - continue; - } - - // OSC sequence: ESC ] - if (nextChar === ']') { - index++; - - let validSequence = true; - const maxOscLength = 256; // Reasonable limit for OSC sequences - const startIdx = index; - - while (index < input.length && index - startIdx < maxOscLength) { - const seqChar = input.charCodeAt(index); - - // BEL terminates OSC - if (seqChar === 0x07) { - index++; - - break; - } - - // ST (ESC \) terminates OSC - if (seqChar === 0x1b && index + 1 < input.length && input.charAt(index + 1) === '\\') { - index += 2; - - break; - } - - // Only allow printable ASCII in OSC sequences - if (seqChar > 0x7e || (seqChar < 0x20 && seqChar !== 0x07)) { - validSequence = false; - - break; - } - - index++; - } - - // Check if we exceeded max length without finding terminator - if (index - startIdx >= maxOscLength) { - validSequence = false; - } - - if (validSequence) { - result.push(input.slice(escapeStart, index)); - } else { - result.push('.'); - index = escapeStart + 1; - } - - continue; - } - - // Simple escape sequences: ESC followed by single ASCII char - if (nextCharCode >= 0x20 && nextCharCode <= 0x7e) { - // Common escape sequences - if (/[78cDEHMNOPVWXZ\\^_=><()]/.test(nextChar)) { - index++; - result.push(input.slice(escapeStart, index)); - - continue; - } - } - } - - // Unknown or invalid escape - replace with dot - result.push('.'); - continue; - } - - // Preserve standard whitespace characters - if (charCode === 0x09 || charCode === 0x0a || charCode === 0x0d) { - result.push(input.charAt(index)); - index++; - continue; - } - - // ASCII printable range (0x20-0x7E) - safe to display - if (charCode >= 0x20 && charCode <= 0x7e) { - result.push(input.charAt(index)); - index++; - continue; - } - - // Everything else (control chars, high-bit chars, Unicode) -> dot - // This includes: - // - Control characters 0x00-0x1F (except tab, LF, CR) - // - DEL (0x7F) - // - C1 control characters (0x80-0x9F) - // - All Unicode characters above 0x7F - // - Surrogate pairs, emoji, CJK, Cyrillic, etc. - result.push('.'); - index++; - } - - return result.join(''); -}; - -/** - * Checks if a string contains potentially problematic characters for xterm.js. - * Returns true if the string needs sanitization. - * - * @param input - The string to check - * @returns true if string contains problematic characters - */ -const needsSanitization = (input: string): boolean => { - if (!input) { - return false; - } - - for (let index = 0; index < input.length; index++) { - const charCode = input.charCodeAt(index); - - // Allow standard whitespace (tab, LF, CR) - if (charCode === 0x09 || charCode === 0x0a || charCode === 0x0d) { - continue; - } - - // Allow ASCII printable range (0x20-0x7E) - if (charCode >= 0x20 && charCode <= 0x7e) { - continue; - } - - // Allow ESC character (start of escape sequences) - if (charCode === 0x1b) { - // Quick validation of escape sequence - if (index + 1 < input.length) { - const nextChar = input.charAt(index + 1); - - // Common valid escape sequences: ESC[, ESC], ESC(, ESC), etc. - if ('[]()\\_'.includes(nextChar)) { - continue; - } - } - } - - // Found problematic character (control chars, high-bit, Unicode) - return true; - } - - return false; -}; - -const terminalOptions: ITerminalOptions = { - allowProposedApi: true, - allowTransparency: true, - convertEol: true, - cursorBlink: false, - customGlyphs: true, - disableStdin: true, - fastScrollSensitivity: 10, - fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace', - fontSize: 12, - fontWeight: 600, - screenReaderMode: false, - scrollback: 2500, - smoothScrollDuration: 0, // Disable smooth scrolling -} as const; - -// Search decoration styles for dark theme - using HEX format as required -const darkSearchDecorations = { - activeMatchBackground: '#AAAAAA', - activeMatchColorOverviewRuler: '#000000', - matchBackground: '#666666', - matchOverviewRuler: '#000000', -} as const; - -// Search decoration styles for light theme - using HEX format as required -const lightSearchDecorations = { - activeMatchBackground: '#555555', - activeMatchColorOverviewRuler: '#000000', - matchBackground: '#000000', - matchOverviewRuler: '#000000', -} as const; - -const darkTheme: ITheme = { - background: '#050c13', - black: '#f4f4f5', - blue: '#60a5fa', - brightBlack: '#e4e4e7', - brightBlue: '#93c5fd', - brightCyan: '#67e8f9', - brightGreen: '#86efac', - brightMagenta: '#d8b4fe', - brightRed: '#fca5a5', - brightWhite: '#71717a', - brightYellow: '#fde047', - cursor: '#f4f4f5', - cursorAccent: '#f4f4f5', - cyan: '#22d3ee', - foreground: '#f4f4f5', - green: '#4ade80', - magenta: '#c084fc', - red: '#f87171', - selectionBackground: 'rgba(96, 165, 250, 0.2)', - white: '#050c13', - yellow: '#facc15', -} as const; - -const lightTheme: ITheme = { - background: '#ffffff', - black: '#020817', - blue: '#3b82f6', - brightBlack: '#64748b', - brightBlue: '#60a5fa', - brightCyan: '#22d3ee', - brightGreen: '#4ade80', - brightMagenta: '#c084fc', - brightRed: '#f87171', - brightWhite: '#f1f5f9', - brightYellow: '#facc15', - cursor: '#020817', - cursorAccent: '#020817', - cyan: '#06b6d4', - foreground: '#020817', - green: '#22c55e', - magenta: '#a855f7', - red: '#ef4444', - selectionBackground: 'rgba(59, 130, 246, 0.1)', - white: '#e2e8f0', - yellow: '#eab308', -} as const; - -const processLog = (log: string): string => - needsSanitization(log) ? sanitizeTerminalOutput(log) : log; - -type TerminalLifecycle = 'idle' | 'opened' | 'ready'; - -interface TerminalProps { - className?: string; - logs: string[]; - searchValue?: string; -} - -interface TerminalRef { - findNext: () => void; - findPrevious: () => void; -} - -const Terminal = ({ - className, - logs, - ref, - searchValue, -}: TerminalProps & { ref?: React.RefObject }) => { - const terminalRef = useRef(null); - const xtermRef = useRef(null); - const fitAddonRef = useRef(null); - const searchAddonRef = useRef(null); - const lastLogIndexRef = useRef(0); - const webglAddonRef = useRef(null); - const unicodeAddonRef = useRef(null); - const webLinksAddonRef = useRef(null); - const contextLossDisposableRef = useRef void }>(null); - const resizeObserverRef = useRef(null); - const debouncedFitRef = useRef>(null); - const { theme } = useTheme(); - const [lifecycle, setLifecycle] = useReducer((_: TerminalLifecycle, next: TerminalLifecycle) => next, 'idle'); - const lifecycleRef = useRef('idle'); - const prevLogsLengthRef = useRef(0); - const terminalInitializedRef = useRef(false); - const isMountedRef = useRef(true); - const initTimeoutRef = useRef(null); - const fitTimeoutRef = useRef(null); - - // Determine if current effective theme is dark (considering system preference) - const isDarkTheme = useCallback(() => { - if (theme === 'dark') { - return true; - } - - if (theme === 'light') { - return false; - } - - // For 'system' theme, check browser's system preference - return window.matchMedia('(prefers-color-scheme: dark)').matches; - }, [theme]); - - // Get search decorations based on current theme - const getSearchDecorations = useCallback(() => { - return isDarkTheme() ? darkSearchDecorations : lightSearchDecorations; - }, [isDarkTheme]); - - // Expose methods to parent component via ref - useImperativeHandle( - ref, - () => ({ - findNext: () => { - if (searchAddonRef.current && searchValue?.trim()) { - try { - searchAddonRef.current.findNext(searchValue.trim(), { - caseSensitive: false, - decorations: getSearchDecorations(), - regex: false, - wholeWord: false, - }); - } catch (error: unknown) { - Log.error('Terminal findNext failed:', error); - } - } - }, - findPrevious: () => { - if (searchAddonRef.current && searchValue?.trim()) { - try { - searchAddonRef.current.findPrevious(searchValue.trim(), { - caseSensitive: false, - decorations: getSearchDecorations(), - regex: false, - wholeWord: false, - }); - } catch (error: unknown) { - Log.error('Terminal findPrevious failed:', error); - } - } - }, - }), - [searchValue, getSearchDecorations], - ); - - // Safe terminal operations - const safeTerminalOperation = (operation: () => void) => { - try { - if (isMountedRef.current && xtermRef.current) { - operation(); - } - } catch (error: unknown) { - Log.error('Terminal operation failed:', error); - } - }; - - // Safe fit - const safeFit = () => { - try { - if ( - isMountedRef.current && - fitAddonRef.current && - terminalRef.current && - terminalRef.current.offsetHeight > 0 && - xtermRef.current - ) { - fitAddonRef.current.fit(); - } - } catch (error: unknown) { - Log.error('Terminal fit failed:', error); - } - }; - - // Clear all timeouts - const clearAllTimeouts = () => { - if (initTimeoutRef.current) { - clearTimeout(initTimeoutRef.current); - initTimeoutRef.current = null; - } - - if (fitTimeoutRef.current) { - clearTimeout(fitTimeoutRef.current); - fitTimeoutRef.current = null; - } - }; - - // Track component mount/unmount - useEffect(() => { - isMountedRef.current = true; - - return () => { - isMountedRef.current = false; - clearAllTimeouts(); - }; - }, []); - - // Initialize terminal - only once - useEffect(() => { - if (!terminalRef.current || terminalInitializedRef.current || !isMountedRef.current) { - return; - } - - terminalInitializedRef.current = true; - - try { - // Create terminal instance with optimized settings - const terminal = new XTerminal({ - ...terminalOptions, - theme: isDarkTheme() ? darkTheme : lightTheme, - }); - - xtermRef.current = terminal; - - // Add addons before opening terminal - const fitAddon = new FitAddon(); - fitAddonRef.current = fitAddon; - terminal.loadAddon(fitAddon); - - const searchAddon = new SearchAddon(); - searchAddonRef.current = searchAddon; - terminal.loadAddon(searchAddon); - - const unicodeAddon = new Unicode11Addon(); - unicodeAddonRef.current = unicodeAddon; - terminal.loadAddon(unicodeAddon); - terminal.unicode.activeVersion = '11'; - - const webLinksAddon = new WebLinksAddon(); - webLinksAddonRef.current = webLinksAddon; - terminal.loadAddon(webLinksAddon); - - // Add WebGL addon last (and optionally) - try { - const webglAddon = new WebglAddon(); - webglAddonRef.current = webglAddon; - terminal.loadAddon(webglAddon); - contextLossDisposableRef.current = webglAddon.onContextLoss(() => { - if (isMountedRef.current && webglAddonRef.current) { - webglAddonRef.current.dispose(); - } - }); - } catch { - // Ignore WebGL errors - } - - // Set up resize handler - const debouncedFit = debounce(() => { - if (isMountedRef.current && lifecycleRef.current === 'ready') { - safeFit(); - } - }, 150); - - debouncedFitRef.current = debouncedFit; - - const resizeObserver = new ResizeObserver(() => { - if (isMountedRef.current && lifecycleRef.current === 'ready') { - debouncedFit(); - } - }); - - resizeObserverRef.current = resizeObserver; - - // Open terminal with delay - // This approach ensures the DOM is ready for rendering - initTimeoutRef.current = setTimeout(() => { - if (!isMountedRef.current || !terminalRef.current || !xtermRef.current) { - return; - } - - try { - terminal.open(terminalRef.current); - lifecycleRef.current = 'opened'; - setLifecycle('opened'); - - // Observe size changes only after successful terminal opening - if (terminalRef.current && resizeObserverRef.current) { - resizeObserverRef.current.observe(terminalRef.current); - } - - // Set size with delay to allow DOM to render terminal - fitTimeoutRef.current = setTimeout(() => { - if (isMountedRef.current) { - safeFit(); - lifecycleRef.current = 'ready'; - setLifecycle('ready'); - } - }, 200); - } catch (error: unknown) { - Log.error('Failed to open terminal:', error); - } - }, 100); - - return () => { - // Cleanup on unmount - if (initTimeoutRef.current) { - clearTimeout(initTimeoutRef.current); - } - - if (fitTimeoutRef.current) { - clearTimeout(fitTimeoutRef.current); - } - - clearAllTimeouts(); - - if (resizeObserverRef.current) { - resizeObserverRef.current.disconnect(); - resizeObserverRef.current = null; - } - - if (debouncedFitRef.current) { - debouncedFitRef.current.cancel(); - debouncedFitRef.current = null; - } - - if (unicodeAddonRef.current) { - try { - unicodeAddonRef.current.dispose(); - } catch { - // Ignore errors during disposal - } - - unicodeAddonRef.current = null; - } - - if (webLinksAddonRef.current) { - try { - webLinksAddonRef.current.dispose(); - } catch { - // Ignore errors during disposal - } - - webLinksAddonRef.current = null; - } - - if (searchAddonRef.current) { - try { - searchAddonRef.current.dispose(); - } catch { - // Ignore errors during disposal - } - - searchAddonRef.current = null; - } - - if (contextLossDisposableRef.current) { - try { - contextLossDisposableRef.current.dispose(); - } catch { - // Ignore errors during disposal - } - - contextLossDisposableRef.current = null; - } - - if (webglAddonRef.current) { - try { - webglAddonRef.current.dispose(); - } catch { - // Ignore errors during disposal - } - - webglAddonRef.current = null; - } - - if (fitAddonRef.current) { - try { - fitAddonRef.current.dispose(); - } catch { - // Ignore errors during disposal - } - - fitAddonRef.current = null; - } - - if (xtermRef.current) { - try { - xtermRef.current.dispose(); - } catch { - // Ignore errors during disposal - } - - xtermRef.current = null; - } - - lastLogIndexRef.current = 0; - prevLogsLengthRef.current = 0; - terminalInitializedRef.current = false; - lifecycleRef.current = 'idle'; - setLifecycle('idle'); - }; - } catch (error: unknown) { - Log.error('Terminal initialization failed:', error); - terminalInitializedRef.current = false; - - return; - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - // Handle search functionality with decorations - useEffect(() => { - if (!searchAddonRef.current || lifecycle !== 'ready' || !isMountedRef.current) { - return; - } - - const searchAddon = searchAddonRef.current; - - try { - if (searchValue && searchValue.trim()) { - // Perform search with theme-appropriate decorations - searchAddon.findNext(searchValue.trim(), { - caseSensitive: false, - decorations: getSearchDecorations(), - regex: false, - wholeWord: false, - }); - } else { - // Clear search highlighting when search value is empty - searchAddon.clearDecorations(); - } - } catch (error: unknown) { - Log.error('Terminal search failed:', error); - } - }, [searchValue, lifecycle, getSearchDecorations]); - - // Update theme and listen to system theme changes - useEffect(() => { - const updateTerminalTheme = () => { - safeTerminalOperation(() => { - if (xtermRef.current) { - xtermRef.current.options.theme = isDarkTheme() ? darkTheme : lightTheme; - } - }); - }; - - // Update theme immediately - updateTerminalTheme(); - - // Listen to system theme changes only when theme is 'system' - if (theme === 'system') { - const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); - - const handleSystemThemeChange = () => { - updateTerminalTheme(); - }; - - mediaQuery.addEventListener('change', handleSystemThemeChange); - - return () => { - mediaQuery.removeEventListener('change', handleSystemThemeChange); - }; - } - }, [theme, isDarkTheme]); - - // Update logs only when terminal is fully ready - useEffect(() => { - if (!isMountedRef.current || !xtermRef.current || lifecycle !== 'ready') { - return; - } - - const terminal = xtermRef.current; - - try { - if (logs?.length === 0 && prevLogsLengthRef.current > 0) { - safeTerminalOperation(() => { - terminal.clear(); - }); - lastLogIndexRef.current = 0; - prevLogsLengthRef.current = 0; - - return; - } - - if (!logs?.length) { - return; - } - - if (logs.length >= lastLogIndexRef.current) { - const newLogs = logs.slice(lastLogIndexRef.current); - - if (newLogs.length === 0) { - return; - } - - safeTerminalOperation(() => { - const batch = newLogs.filter(Boolean).map(processLog).join('\r\n'); - - if (batch) { - terminal.write(batch + '\r\n'); - terminal.scrollToBottom(); - } - }); - - lastLogIndexRef.current = logs.length; - prevLogsLengthRef.current = logs.length; - } else { - safeTerminalOperation(() => { - terminal.clear(); - - const batch = logs.filter(Boolean).map(processLog).join('\r\n'); - - if (batch) { - terminal.write(batch + '\r\n'); - } - - terminal.scrollToBottom(); - }); - - lastLogIndexRef.current = logs.length; - prevLogsLengthRef.current = logs.length; - } - } catch (error) { - Log.error('Terminal log update failed:', error); - } - }, [logs, lifecycle]); - - return ( -
- ); -}; - -Terminal.displayName = 'Terminal'; - -export default Terminal; diff --git a/frontend/src/components/shared/terminal/index.ts b/frontend/src/components/shared/terminal/index.ts new file mode 100644 index 00000000..89a81b89 --- /dev/null +++ b/frontend/src/components/shared/terminal/index.ts @@ -0,0 +1,2 @@ +export type { TerminalRef } from './terminal'; +export { default } from './terminal'; diff --git a/frontend/src/components/shared/terminal/terminal-config.ts b/frontend/src/components/shared/terminal/terminal-config.ts new file mode 100644 index 00000000..a1f5f188 --- /dev/null +++ b/frontend/src/components/shared/terminal/terminal-config.ts @@ -0,0 +1,100 @@ +import type { ITerminalOptions, ITheme } from '@xterm/xterm'; + +export const TERMINAL_OPTIONS: ITerminalOptions = { + allowProposedApi: true, + allowTransparency: true, + convertEol: true, + cursorBlink: false, + customGlyphs: true, + disableStdin: true, + fastScrollSensitivity: 10, + fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace', + fontSize: 12, + fontWeight: 600, + logLevel: 'off', + screenReaderMode: false, + scrollback: 10_000, + smoothScrollDuration: 0, +} as const; + +const DARK_THEME: ITheme = { + background: '#050c13', + black: '#f4f4f5', + blue: '#60a5fa', + brightBlack: '#e4e4e7', + brightBlue: '#93c5fd', + brightCyan: '#67e8f9', + brightGreen: '#86efac', + brightMagenta: '#d8b4fe', + brightRed: '#fca5a5', + brightWhite: '#71717a', + brightYellow: '#fde047', + cursor: '#f4f4f5', + cursorAccent: '#f4f4f5', + cyan: '#22d3ee', + foreground: '#f4f4f5', + green: '#4ade80', + magenta: '#c084fc', + red: '#f87171', + selectionBackground: 'rgba(96, 165, 250, 0.2)', + white: '#050c13', + yellow: '#facc15', +} as const; + +const LIGHT_THEME: ITheme = { + background: '#ffffff', + black: '#020817', + blue: '#3b82f6', + brightBlack: '#64748b', + brightBlue: '#60a5fa', + brightCyan: '#22d3ee', + brightGreen: '#4ade80', + brightMagenta: '#c084fc', + brightRed: '#f87171', + brightWhite: '#f1f5f9', + brightYellow: '#facc15', + cursor: '#020817', + cursorAccent: '#020817', + cyan: '#06b6d4', + foreground: '#020817', + green: '#22c55e', + magenta: '#a855f7', + red: '#ef4444', + selectionBackground: 'rgba(59, 130, 246, 0.1)', + white: '#e2e8f0', + yellow: '#eab308', +} as const; + +const DARK_SEARCH_DECORATIONS = { + activeMatchBackground: '#AAAAAA', + activeMatchColorOverviewRuler: '#000000', + matchBackground: '#666666', + matchOverviewRuler: '#000000', +} as const; + +const LIGHT_SEARCH_DECORATIONS = { + activeMatchBackground: '#555555', + activeMatchColorOverviewRuler: '#000000', + matchBackground: '#000000', + matchOverviewRuler: '#000000', +} as const; + +export function getSearchDecorations(isDark: boolean) { + return isDark ? DARK_SEARCH_DECORATIONS : LIGHT_SEARCH_DECORATIONS; +} + +export function getTerminalTheme(isDark: boolean): ITheme { + return isDark ? DARK_THEME : LIGHT_THEME; +} + +export function isDarkMode(theme: 'dark' | 'light' | 'system'): boolean { + if (theme === 'dark') { + return true; + } + + if (theme === 'light') { + return false; + } + + return window.matchMedia('(prefers-color-scheme: dark)').matches; +} diff --git a/frontend/src/components/shared/terminal/terminal-sanitizer.test.ts b/frontend/src/components/shared/terminal/terminal-sanitizer.test.ts new file mode 100644 index 00000000..47b1a82c --- /dev/null +++ b/frontend/src/components/shared/terminal/terminal-sanitizer.test.ts @@ -0,0 +1,622 @@ +import { describe, expect, it } from 'vitest'; + +import { needsSanitization, processLog, sanitizeTerminalOutput } from './terminal-sanitizer'; + +// Helper: check that no C1 control bytes (0x80-0x9F) remain in output +function hasC1(s: string): boolean { + for (let i = 0; i < s.length; i++) { + const c = s.charCodeAt(i); + + if (c >= 0x80 && c <= 0x9f) { + return true; + } + } + + return false; +} + +// Helper: check that no ESC byte (0x1B) remains in output +function hasEsc(s: string): boolean { + return s.includes('\x1b'); +} + +describe('needsSanitization', () => { + it('returns false for pure ASCII', () => { + expect(needsSanitization('Hello World 123 !@#')).toBe(false); + }); + + it('returns false for empty/null', () => { + expect(needsSanitization('')).toBe(false); + expect(needsSanitization(null as unknown as string)).toBe(false); + }); + + it('returns false for whitespace (TAB, LF, CR)', () => { + expect(needsSanitization('line1\nline2\ttab\rcarriage')).toBe(false); + }); + + it('returns false for valid Unicode (Cyrillic, CJK, Latin Extended)', () => { + expect(needsSanitization('Привет мир')).toBe(false); + expect(needsSanitization('你好世界')).toBe(false); + expect(needsSanitization('café résumé')).toBe(false); + }); + + it('returns false for box-drawing characters', () => { + expect(needsSanitization('━━━┃━━━')).toBe(false); + }); + + it('returns false for valid emoji (surrogate pairs)', () => { + expect(needsSanitization('Hello 🌍 World')).toBe(false); + expect(needsSanitization('😀😁😂')).toBe(false); + expect(needsSanitization('🇺🇸')).toBe(false); + }); + + it('returns true for ESC sequences', () => { + expect(needsSanitization('\x1b[31m')).toBe(true); + expect(needsSanitization('text\x1b')).toBe(true); + }); + + it('returns true for C0 control characters', () => { + expect(needsSanitization('\x00')).toBe(true); + expect(needsSanitization('\x07')).toBe(true); + expect(needsSanitization('text\x01more')).toBe(true); + }); + + it('returns true for C1 control characters', () => { + expect(needsSanitization('\x80')).toBe(true); + expect(needsSanitization('\x90')).toBe(true); + expect(needsSanitization('\x9b')).toBe(true); + }); + + it('returns true for DEL', () => { + expect(needsSanitization('\x7f')).toBe(true); + }); + + it('returns true for U+FFFD', () => { + expect(needsSanitization('\ufffd')).toBe(true); + }); + + it('returns true for lone surrogates', () => { + expect(needsSanitization('\uD83C')).toBe(true); + expect(needsSanitization('\uDFFF')).toBe(true); + expect(needsSanitization('text\uD800')).toBe(true); + }); +}); + +describe('sanitizeTerminalOutput', () => { + describe('CSI SGR (colors/styles) — preserved', () => { + it('preserves SGR red', () => { + expect(processLog('\x1b[31m')).toBe('\x1b[31m'); + }); + + it('preserves SGR reset', () => { + expect(processLog('\x1b[0m')).toBe('\x1b[0m'); + }); + + it('preserves SGR bold+red', () => { + expect(processLog('\x1b[1;31m')).toBe('\x1b[1;31m'); + }); + + it('preserves SGR 256-color', () => { + expect(processLog('\x1b[38;5;196m')).toBe('\x1b[38;5;196m'); + }); + + it('preserves SGR RGB', () => { + expect(processLog('\x1b[38;2;255;0;0m')).toBe('\x1b[38;2;255;0;0m'); + }); + + it('preserves SGR in context with surrounding text', () => { + const input = 'hello \x1b[31mred\x1b[0m normal'; + + expect(processLog(input)).toBe(input); + }); + + it('preserves multiple SGR sequences', () => { + const input = '\x1b[1m\x1b[31mBOLD RED\x1b[0m'; + + expect(processLog(input)).toBe(input); + }); + }); + + describe('CSI non-SGR — blocked', () => { + it('strips ED: erase display', () => { + expect(hasEsc(processLog('a\x1b[2Jb'))).toBe(false); + }); + + it('strips ED: erase scrollback', () => { + expect(hasEsc(processLog('a\x1b[3Jb'))).toBe(false); + }); + + it('strips CUP: cursor home', () => { + expect(hasEsc(processLog('a\x1b[Hb'))).toBe(false); + }); + + it('strips SU: scroll up', () => { + expect(hasEsc(processLog('a\x1b[999Sb'))).toBe(false); + }); + + it('strips SD: scroll down', () => { + expect(hasEsc(processLog('a\x1b[999Tb'))).toBe(false); + }); + + it('strips DECSET: alternate buffer', () => { + expect(hasEsc(processLog('a\x1b[?1049hb'))).toBe(false); + }); + + it('strips DECSET: hide cursor', () => { + expect(hasEsc(processLog('a\x1b[?25lb'))).toBe(false); + }); + + it('strips DL: delete lines', () => { + expect(hasEsc(processLog('a\x1b[10Mb'))).toBe(false); + }); + + it('strips IL: insert lines', () => { + expect(hasEsc(processLog('a\x1b[10Lb'))).toBe(false); + }); + + it('strips ICH: insert characters', () => { + expect(hasEsc(processLog('a\x1b[10@b'))).toBe(false); + }); + + it('preserves surrounding text when stripping CSI', () => { + const out = processLog('before\x1b[2Jafter'); + + expect(out).toContain('before'); + expect(out).toContain('after'); + }); + }); + + describe('CSI length limit', () => { + it('strips CSI exceeding MAX_CSI_LENGTH', () => { + const input = '\x1b[' + '1;'.repeat(1000) + 'm'; + + expect(processLog(input)).toMatch(/^\./); + expect(processLog(input)).not.toContain('\x1b'); + }); + }); + + describe('OSC 8 hyperlinks — safe protocols preserved', () => { + it('preserves https link', () => { + const input = '\x1b]8;;https://example.com\x07Click\x1b]8;;\x07'; + + expect(processLog(input)).toContain('\x1b]8;;https://example.com\x07'); + }); + + it('preserves http link', () => { + const input = '\x1b]8;;http://example.com\x07Click\x1b]8;;\x07'; + + expect(processLog(input)).toContain('\x1b]8;;http://'); + }); + + it('preserves mailto link', () => { + const input = '\x1b]8;;mailto:user@example.com\x07Click\x1b]8;;\x07'; + + expect(processLog(input)).toContain('\x1b]8;;mailto:'); + }); + + it('preserves close tag (BEL terminated)', () => { + expect(processLog('\x1b]8;;\x07')).toBe('\x1b]8;;\x07'); + }); + + it('preserves close tag (ST terminated)', () => { + expect(processLog('\x1b]8;;\x1b\\')).toBe('\x1b]8;;\x1b\\'); + }); + + it('preserves OSC 8 with id param', () => { + const input = '\x1b]8;id=foo;https://example.com\x07Click\x1b]8;;\x07'; + + expect(processLog(input)).toContain('\x1b]8;id=foo;https://example.com'); + }); + + it('preserves ssh link', () => { + const input = '\x1b]8;;ssh://user@host.com\x07Click\x1b]8;;\x07'; + + expect(processLog(input)).toContain('\x1b]8;;ssh://'); + }); + + it('preserves telnet link', () => { + const input = '\x1b]8;;telnet://host:23\x07Click\x1b]8;;\x07'; + + expect(processLog(input)).toContain('\x1b]8;;telnet://'); + }); + + it('preserves full link with ST terminator', () => { + const input = '\x1b]8;;https://example.com\x1b\\Click\x1b]8;;\x1b\\'; + + expect(processLog(input)).toContain('\x1b]8;;https://example.com\x1b\\'); + }); + + it('preserves long URL up to MAX_OSC_LENGTH (2048)', () => { + const longPath = '/path/' + 'a'.repeat(500); + const input = '\x1b]8;;https://example.com' + longPath + '\x07Click\x1b]8;;\x07'; + + expect(processLog(input)).toContain('https://example.com' + longPath); + }); + }); + + describe('OSC 8 hyperlinks — dangerous protocols blocked', () => { + it('strips javascript: URI', () => { + const input = '\x1b]8;;javascript:alert(1)\x07Click\x1b]8;;\x07'; + const out = processLog(input); + + expect(out).not.toContain('\x1b]8;;javascript:'); + }); + + it('strips data: URI', () => { + const input = '\x1b]8;;data:text/html,

XSS

\x07Click\x1b]8;;\x07'; + const out = processLog(input); + + expect(out).not.toContain('\x1b]8;;data:'); + }); + + it('strips ftp: URI', () => { + const input = '\x1b]8;;ftp://evil.com/shell\x07Click\x1b]8;;\x07'; + const out = processLog(input); + + expect(out).not.toContain('\x1b]8;;ftp:'); + }); + + it('strips file: URI', () => { + const input = '\x1b]8;;file:///etc/passwd\x07Click\x1b]8;;\x07'; + const out = processLog(input); + + expect(out).not.toContain('\x1b]8;;file:'); + }); + + it('preserves safe close tag even when open tag is stripped', () => { + const input = '\x1b]8;;javascript:x\x07Click\x1b]8;;\x07'; + const out = processLog(input); + + expect(out).toContain('\x1b]8;;\x07'); + }); + + it('strips OSC 8 with missing second semicolon', () => { + const input = '\x1b]8;https://example.com\x07Click\x1b]8;;\x07'; + const out = processLog(input); + + expect(out).not.toContain('\x1b]8;https:'); + }); + + it('strips OSC 8 with unknown protocol', () => { + const input = '\x1b]8;;custom://something\x07Click\x1b]8;;\x07'; + const out = processLog(input); + + expect(out).not.toContain('\x1b]8;;custom:'); + }); + }); + + describe('OSC non-hyperlink — blocked', () => { + it('strips OSC 0: window title', () => { + expect(hasEsc(processLog('\x1b]0;HACKED\x07'))).toBe(false); + }); + + it('strips OSC 2: window title', () => { + expect(hasEsc(processLog('\x1b]2;HACKED\x07'))).toBe(false); + }); + + it('strips OSC 10: foreground color', () => { + expect(hasEsc(processLog('\x1b]10;#ff0000\x07'))).toBe(false); + }); + + it('strips OSC 11: background color', () => { + expect(hasEsc(processLog('\x1b]11;#00ff00\x07'))).toBe(false); + }); + + it('strips OSC 12: cursor color', () => { + expect(hasEsc(processLog('\x1b]12;#0000ff\x07'))).toBe(false); + }); + + it('strips OSC 4: palette color', () => { + expect(hasEsc(processLog('\x1b]4;1;#ff0000\x07'))).toBe(false); + }); + }); + + describe('ESC simple sequences — all blocked', () => { + it('strips ESC 7 (save cursor)', () => { + expect(hasEsc(processLog('\x1b7'))).toBe(false); + }); + + it('strips ESC 8 (restore cursor)', () => { + expect(hasEsc(processLog('\x1b8'))).toBe(false); + }); + + it('strips ESC D (index)', () => { + expect(hasEsc(processLog('\x1bD'))).toBe(false); + }); + + it('strips ESC E (next line)', () => { + expect(hasEsc(processLog('\x1bE'))).toBe(false); + }); + + it('strips ESC M (reverse index)', () => { + expect(hasEsc(processLog('\x1bM'))).toBe(false); + }); + + it('strips ESC _ (APC) with body consumed', () => { + const out = processLog('\x1b_payload\x1b\\'); + + expect(hasEsc(out)).toBe(false); + expect(out).not.toContain('payload'); + }); + + it('strips ESC ^ (PM) with body consumed', () => { + const out = processLog('\x1b^payload\x1b\\'); + + expect(hasEsc(out)).toBe(false); + expect(out).not.toContain('payload'); + }); + + it('strips ESC X (SOS) with body consumed', () => { + const out = processLog('\x1bXpayload\x1b\\'); + + expect(hasEsc(out)).toBe(false); + expect(out).not.toContain('payload'); + }); + + it('strips ESC P (DCS) with body consumed', () => { + const out = processLog('\x1bP0;1|data\x1b\\'); + + expect(hasEsc(out)).toBe(false); + expect(out).not.toContain('data'); + }); + + it('strips ESC ( (charset G0)', () => { + expect(hasEsc(processLog('\x1b(B'))).toBe(false); + }); + + it('strips truncated ESC at end of input', () => { + expect(processLog('text\x1b')).toBe('text.'); + }); + }); + + describe('C0 control characters', () => { + it('preserves TAB', () => { + expect(processLog('a\tb')).toBe('a\tb'); + }); + + it('preserves LF', () => { + expect(processLog('a\nb')).toBe('a\nb'); + }); + + it('preserves CR', () => { + expect(processLog('a\rb')).toBe('a\rb'); + }); + + it('strips NUL via binary detection', () => { + expect(processLog('x\x00y')).toBe('[binary data]'); + }); + + it('strips BEL', () => { + expect(processLog('before\x07after')).toBe('before.after'); + }); + + it('strips BS', () => { + expect(processLog('before\x08after')).toBe('before.after'); + }); + + it('strips SOH, STX, ETX', () => { + expect(processLog('a\x01\x02\x03b')).toBe('a...b'); + }); + }); + + describe('C1 control characters (0x80-0x9F)', () => { + it('strips all C1 bytes', () => { + const padding = 'A'.repeat(400); + const c1Chars = String.fromCharCode(...Array.from({ length: 32 }, (_, i) => 0x80 + i)); + const input = padding + c1Chars + 'end'; + const out = processLog(input); + + expect(hasC1(out)).toBe(false); + expect(out).toContain('end'); + }); + + it('strips C1 DCS (0x90)', () => { + expect(processLog('a\x90b')).toBe('a.b'); + }); + + it('strips C1 CSI (0x9B)', () => { + expect(processLog('a\x9bb')).toBe('a.b'); + }); + + it('strips C1 OSC (0x9D)', () => { + expect(processLog('a\x9db')).toBe('a.b'); + }); + }); + + describe('DEL and U+FFFD', () => { + it('strips DEL (0x7F)', () => { + expect(processLog('a\x7fb')).toBe('a.b'); + }); + + it('strips U+FFFD', () => { + expect(processLog('a\ufffdb')).toBe('a.b'); + }); + }); + + describe('Unicode preservation', () => { + it('preserves Cyrillic', () => { + expect(processLog('Привет мир')).toBe('Привет мир'); + }); + + it('preserves CJK', () => { + expect(processLog('你好世界')).toBe('你好世界'); + }); + + it('preserves Latin Extended', () => { + expect(processLog('café résumé')).toBe('café résumé'); + }); + + it('preserves emoji', () => { + expect(processLog('Hello 🌍 World')).toBe('Hello 🌍 World'); + }); + + it('preserves box-drawing', () => { + expect(processLog('━━━┃━━━')).toBe('━━━┃━━━'); + }); + + it('preserves mixed Unicode with SGR', () => { + const input = '\x1b[31mПривет\x1b[0m 🌍'; + + expect(processLog(input)).toBe(input); + }); + }); + + describe('binary content detection', () => { + it('detects null byte as binary', () => { + expect(processLog('text\x00more')).toBe('[binary data]'); + }); + + it('detects lone surrogates as binary', () => { + const input = 'A'.repeat(100) + '\uD800' + 'B'.repeat(100); + + expect(processLog(input)).toBe('[binary data]'); + }); + + it('detects high control char density as binary', () => { + const input = Array.from({ length: 100 }, (_, i) => String.fromCharCode(i < 15 ? 0x01 + i : 0x41)).join(''); + + expect(processLog(input)).toBe('[binary data]'); + }); + + it('does not false-positive on short strings with BEL', () => { + expect(processLog('\x1b]8;;\x07')).not.toBe('[binary data]'); + }); + + it('does not false-positive on short strings without null', () => { + expect(processLog('\x07\x08\x01')).not.toBe('[binary data]'); + }); + + it('passes through valid content after 512-byte sample window', () => { + const input = 'A'.repeat(600) + '\x90'; + const out = processLog(input); + + expect(out).not.toBe('[binary data]'); + expect(hasC1(out)).toBe(false); + }); + }); + + describe('edge cases', () => { + it('handles empty string', () => { + expect(sanitizeTerminalOutput('')).toBe(''); + }); + + it('handles null', () => { + expect(sanitizeTerminalOutput(null as unknown as string)).toBe(null); + }); + + it('handles double ESC', () => { + expect(processLog('\x1b\x1b')).toBe('..'); + }); + + it('handles ESC followed by non-printable', () => { + expect(processLog('\x1b\x01')).toBe('..'); + }); + + it('handles ESC [ without final byte', () => { + const out = processLog('\x1b['); + + expect(out).not.toContain('\x1b'); + }); + + it('handles very long input without stack overflow', () => { + const input = '\x1b[31m' + 'A'.repeat(1_000_000) + '\x1b[0m'; + const out = processLog(input); + + expect(out).toContain('\x1b[31m'); + expect(out.length).toBe(input.length); + }); + }); + + describe('processLog fast path', () => { + it('skips sanitization for clean ASCII', () => { + const input = 'Hello World'; + + expect(processLog(input)).toBe(input); + }); + + it('skips sanitization for clean Unicode', () => { + const input = 'Привет мир 🌍'; + + expect(processLog(input)).toBe(input); + }); + + it('runs sanitization for strings with ESC', () => { + const input = '\x1b[31mred\x1b[0m'; + + expect(processLog(input)).toBe(input); + }); + }); + + describe('string sequence body consumption (DCS/APC/PM/SOS)', () => { + it('consumes DCS body: ESC P body ESC \\ → single dot', () => { + expect(processLog('before\x1bPbody\x1b\\after')).toBe('before.after'); + }); + + it('consumes APC body: ESC _ body ESC \\ → single dot', () => { + expect(processLog('before\x1b_body\x1b\\after')).toBe('before.after'); + }); + + it('consumes PM body: ESC ^ body ESC \\ → single dot', () => { + expect(processLog('before\x1b^body\x1b\\after')).toBe('before.after'); + }); + + it('consumes SOS body: ESC X body ESC \\ → single dot', () => { + expect(processLog('before\x1bXbody\x1b\\after')).toBe('before.after'); + }); + + it('consumes DCS with C1 ST terminator (0x9C)', () => { + expect(processLog('before\x1bPbody\x9cafter')).toBe('before.after'); + }); + + it('handles unterminated DCS — consumes until end of input', () => { + const out = processLog('before\x1bPbody'); + + expect(out).toBe('before.'); + expect(out).not.toContain('body'); + }); + + it('handles unterminated APC — consumes until end of input', () => { + const out = processLog('before\x1b_body'); + + expect(out).toBe('before.'); + expect(out).not.toContain('body'); + }); + + it('consumes DCS with ESC sequences inside body', () => { + const out = processLog('before\x1bP\x1b[31m\x1b\\after'); + + expect(out).toBe('before.after'); + }); + + it('consumes long DCS body without hanging', () => { + const longBody = 'X'.repeat(50_000); + const out = processLog('a\x1bP' + longBody + '\x1b\\b'); + + expect(out).toBe('a.b'); + }); + }); + + describe('performance', () => { + it('processes 1M chars in under 500ms', () => { + const input = '\x1b[31m' + 'A'.repeat(100_000) + '\x1b[0m'; + const start = performance.now(); + + for (let i = 0; i < 100; i++) { + sanitizeTerminalOutput(input); + } + + const duration = performance.now() - start; + + expect(duration).toBeLessThan(500); + }); + + it('handles 1M ESC bytes without ReDoS', () => { + const input = '\x1b'.repeat(1_000_000); + const start = performance.now(); + sanitizeTerminalOutput(input); + const duration = performance.now() - start; + + expect(duration).toBeLessThan(1000); + }); + }); +}); diff --git a/frontend/src/components/shared/terminal/terminal-sanitizer.ts b/frontend/src/components/shared/terminal/terminal-sanitizer.ts new file mode 100644 index 00000000..5922090b --- /dev/null +++ b/frontend/src/components/shared/terminal/terminal-sanitizer.ts @@ -0,0 +1,395 @@ +const BINARY_SAMPLE_SIZE = 512; +const BINARY_CONTROL_RATIO = 0.1; +const MAX_CSI_LENGTH = 256; +const MAX_OSC_LENGTH = 2048; +const MAX_STRING_SEQUENCE_LENGTH = 100_000; + +const SAFE_OSC8_PROTOCOLS = ['http://', 'https://', 'mailto:', 'ssh://', 'telnet://']; + +/** + * Quick scan to determine if a string needs full sanitization. + * Returns false for strings that are purely ASCII printable + whitespace + valid Unicode. + */ +export function needsSanitization(input: string): boolean { + if (!input) { + return false; + } + + for (let i = 0; i < input.length; i++) { + const code = input.charCodeAt(i); + + if (code === 0x09 || code === 0x0a || code === 0x0d) { + continue; + } + + if (code >= 0x20 && code <= 0x7e) { + continue; + } + + if (code >= 0xa0) { + if (code >= 0xd800 && code <= 0xdbff) { + if (i + 1 < input.length) { + const next = input.charCodeAt(i + 1); + + if (next >= 0xdc00 && next <= 0xdfff) { + i++; + + continue; + } + } + + return true; + } + + if ((code >= 0xdc00 && code <= 0xdfff) || code === 0xfffd) { + return true; + } + + continue; + } + + return true; + } + + return false; +} + +export function processLog(log: string): string { + return needsSanitization(log) ? sanitizeTerminalOutput(log) : log; +} + +/** + * Sanitizes terminal output for safe display in a read-only xterm.js terminal. + * + * Uses a slice-based approach: tracks ranges of safe characters and extracts + * them via input.slice() instead of pushing individual characters, reducing + * allocations from millions to thousands on large inputs. + * + * Preserves: + * - Valid Unicode text (Cyrillic, CJK, Latin Extended, emoji, etc.) + * - CSI SGR sequences (text color/style: ESC[...m) + * - OSC 8 hyperlinks with safe protocols (http, https, mailto, ssh, telnet) + * + * Strips: + * - C0/C1 control characters (except TAB, LF, CR) + * - All non-SGR CSI sequences (cursor movement, erase, scroll, DECSET) + * - OSC color/title/palette changes (OSC 0, 4, 10, 11, 12) + * - OSC 8 links with dangerous protocols (javascript:, data:, etc.) + * - All simple ESC sequences (cursor save/restore, index, charset) + * - DCS, APC, PM, SOS string sequences (body consumed until ST) + * - Binary content (replaced with placeholder) + */ +export function sanitizeTerminalOutput(input: string): string { + if (!input) { + return input; + } + + if (isBinaryContent(input)) { + return '[binary data]'; + } + + const result: string[] = []; + let i = 0; + let safeStart = 0; + + while (i < input.length) { + const code = input.charCodeAt(i); + + // Fast path: ASCII printable (0x20-0x7E) and safe whitespace + if ((code >= 0x20 && code <= 0x7e) || code === 0x09 || code === 0x0a || code === 0x0d) { + i++; + + continue; + } + + // Valid Unicode (>= 0xA0, excluding lone surrogates and U+FFFD) + if (code >= 0xa0) { + // Valid surrogate pair (emoji, supplementary chars) — skip both units + if (code >= 0xd800 && code <= 0xdbff) { + if (i + 1 < input.length) { + const next = input.charCodeAt(i + 1); + + if (next >= 0xdc00 && next <= 0xdfff) { + i += 2; + + continue; + } + } + + // Lone/invalid surrogate — falls through to replacement below + } else if (code !== 0xfffd && !(code >= 0xdc00 && code <= 0xdfff)) { + i++; + + continue; + } + + // Lone surrogates and U+FFFD fall through to replacement below + } + + // We hit a character that needs handling — flush the safe range + if (i > safeStart) { + result.push(input.slice(safeStart, i)); + } + + if (code === 0x1b) { + i++; + + if (i >= input.length) { + result.push('.'); + safeStart = i; + + continue; + } + + const next = input.charCodeAt(i); + + // CSI sequence (ESC [) — only allow SGR (text style/color) + if (next === 0x5b) { + const parsed = parseCsiSequence(input, i + 1); + + if (parsed.end !== -1 && parsed.final === 'm') { + result.push(input.slice(i - 1, parsed.end)); + i = parsed.end; + safeStart = i; + + continue; + } + + result.push('.'); + safeStart = i; + + continue; + } + + // OSC sequence (ESC ]) — only allow safe OSC 8 hyperlinks + if (next === 0x5d) { + const end = parseOscSequence(input, i + 1); + + if (end !== -1) { + const termLen = input.charCodeAt(end - 1) === 0x07 ? 1 : 2; + const oscContent = input.slice(i + 1, end - termLen); + + if (isOscSafe(oscContent)) { + result.push(input.slice(i - 1, end)); + i = end; + safeStart = i; + + continue; + } + } + + result.push('.'); + safeStart = i; + + continue; + } + + // DCS (ESC P), SOS (ESC X), PM (ESC ^), APC (ESC _) — + // consume the entire body up to ST terminator + if (next === 0x50 || next === 0x58 || next === 0x5e || next === 0x5f) { + i = skipToST(input, i + 1); + result.push('.'); + safeStart = i; + + continue; + } + + // All other ESC sequences (cursor save/restore, index, charset, etc.) + result.push('.'); + safeStart = i; + + continue; + } + + // C0 controls, DEL, C1 controls, lone surrogates, U+FFFD + result.push('.'); + i++; + safeStart = i; + } + + if (safeStart < input.length) { + result.push(input.slice(safeStart)); + } + + return result.join(''); +} + +/** + * Heuristic detection of binary content in a JS string. + * Binary data (e.g. JPEG) interpreted as UTF-16 produces null bytes, + * lone surrogates, and dense clusters of control characters. + * + * For short strings (< 32 chars), only checks for null bytes to avoid + * false positives on terminal sequences containing BEL or other C0 codes. + */ +function isBinaryContent(input: string): boolean { + // Short strings: only null byte is a reliable binary indicator + if (input.length < 32) { + return input.includes('\x00'); + } + + let controlCount = 0; + const sampleSize = Math.min(input.length, BINARY_SAMPLE_SIZE); + + for (let i = 0; i < sampleSize; i++) { + const code = input.charCodeAt(i); + + // Null byte — immediate binary indicator + if (code === 0x00) { + return true; + } + + // Lone surrogates indicate corrupted/binary data + if (code >= 0xd800 && code <= 0xdfff) { + if (code <= 0xdbff && i + 1 < input.length) { + const next = input.charCodeAt(i + 1); + + // Valid surrogate pair — skip + if (next >= 0xdc00 && next <= 0xdfff) { + i++; + + continue; + } + } + + return true; + } + + // Count C0 controls (except TAB, LF, CR, ESC which are common in terminal data) + if (code < 0x20 && code !== 0x09 && code !== 0x0a && code !== 0x0d && code !== 0x1b) { + controlCount++; + } + + // Count C1 controls (0x80-0x9F) + if (code >= 0x80 && code <= 0x9f) { + controlCount++; + } + } + + // High density of control characters indicates binary content + return controlCount / sampleSize > BINARY_CONTROL_RATIO; +} + +/** + * Checks if a parsed OSC sequence content is safe for display. + * Only allows OSC 8 hyperlinks with safe protocols (http, https, mailto). + * Blocks OSC 0 (title), OSC 4/10/11/12 (color changes), and unsafe URIs. + * + * OSC 8 format: "8;params;uri" where params is optional key=value pairs. + * Empty URI (close tag "8;;") is always safe. + */ +function isOscSafe(content: string): boolean { + // Must be OSC 8 (hyperlink) + if (!content.startsWith('8;')) { + return false; + } + + // Find the URI after the second semicolon: "8;params;uri" + const secondSemicolon = content.indexOf(';', 2); + + if (secondSemicolon === -1) { + return false; + } + + const uri = content.slice(secondSemicolon + 1); + + // Empty URI = close tag (ESC]8;;BEL) — always safe + if (!uri) { + return true; + } + + // Validate protocol against whitelist (blocks javascript:, data:, ftp:, file:, etc.) + const uriLower = uri.toLowerCase(); + + return SAFE_OSC8_PROTOCOLS.some((protocol) => uriLower.startsWith(protocol)); +} + +/** + * Parses a CSI sequence (ESC [). Returns the end index and final byte, + * or end=-1 if invalid. Enforces a length limit to prevent unbounded scanning. + * + * CSI format: ESC [ [params] [intermediates] final_byte + * - Parameter bytes: 0x30-0x3F (digits, semicolons, etc.) + * - Intermediate bytes: 0x20-0x2F (space, !, ", etc.) + * - Final byte: 0x40-0x7E (letters — 'm' for SGR, 'H' for CUP, etc.) + */ +function parseCsiSequence( + input: string, + start: number, +): { end: number; final: string } { + let i = start; + + while (i < input.length && i - start < MAX_CSI_LENGTH) { + const code = input.charCodeAt(i); + + if (code < 0x20 || code > 0x7e) { + return { end: -1, final: '' }; + } + + i++; + + if (code >= 0x40 && code <= 0x7e) { + return { end: i, final: String.fromCharCode(code) }; + } + } + + return { end: -1, final: '' }; +} + +/** + * Parses an OSC sequence (ESC ]). Terminated by BEL (0x07) or ST (ESC \). + * Returns end index (after terminator), or -1 if invalid. + * + * OSC format: ESC ] content BEL or ESC ] content ESC \ + * Only ASCII printables and BEL are allowed in content per xterm.js parser. + */ +function parseOscSequence(input: string, start: number): number { + let i = start; + + while (i < input.length && i - start < MAX_OSC_LENGTH) { + const code = input.charCodeAt(i); + + if (code === 0x07) { + return i + 1; + } + + if (code === 0x1b && i + 1 < input.length && input.charAt(i + 1) === '\\') { + return i + 2; + } + + if (code > 0x7e || (code < 0x20 && code !== 0x07)) { + return -1; + } + + i++; + } + + return -1; +} + +/** + * Skips a string-type sequence body (DCS, SOS, PM, APC) until the ST terminator. + * ST is either ESC \ (7-bit) or the C1 byte 0x9C (8-bit). + * Returns the index after ST, or the end of input if unterminated. + * Enforces a length limit to prevent unbounded scanning. + */ +function skipToST(input: string, start: number): number { + let i = start; + const limit = Math.min(input.length, start + MAX_STRING_SEQUENCE_LENGTH); + + while (i < limit) { + const code = input.charCodeAt(i); + + if (code === 0x9c) { + return i + 1; + } + + if (code === 0x1b && i + 1 < input.length && input.charCodeAt(i + 1) === 0x5c) { + return i + 2; + } + + i++; + } + + return i; +} diff --git a/frontend/src/components/shared/terminal/terminal.tsx b/frontend/src/components/shared/terminal/terminal.tsx new file mode 100644 index 00000000..81095f19 --- /dev/null +++ b/frontend/src/components/shared/terminal/terminal.tsx @@ -0,0 +1,92 @@ +import { useEffect, useImperativeHandle, useRef } from 'react'; + +import { useTheme } from '@/hooks/use-theme'; +import { cn } from '@/lib/utils'; + +import { processLog } from './terminal-sanitizer'; +import { useTerminalSearch } from './use-terminal-search'; +import { useXterm } from './use-xterm'; + +interface TerminalProps { + className?: string; + logs: string[]; + searchValue?: string; +} + +interface TerminalRef { + findNext: () => void; + findPrevious: () => void; +} + +const Terminal = ({ + className, + logs, + ref, + searchValue, +}: TerminalProps & { ref?: React.RefObject }) => { + const { theme } = useTheme(); + const { clear, containerRef, isReady, scrollToBottom, searchAddon, write } = useXterm({ theme }); + const { findNext, findPrevious } = useTerminalSearch(searchAddon, isReady, searchValue, theme); + + const lastLogIndexRef = useRef(0); + const prevLogsLengthRef = useRef(0); + + useImperativeHandle(ref, () => ({ findNext, findPrevious }), [findNext, findPrevious]); + + useEffect(() => { + if (!isReady) { + return; + } + + if (logs.length === 0 && prevLogsLengthRef.current > 0) { + clear(); + lastLogIndexRef.current = 0; + prevLogsLengthRef.current = 0; + + return; + } + + if (logs.length === 0) { + return; + } + + if (logs.length >= lastLogIndexRef.current) { + const newLogs = logs.slice(lastLogIndexRef.current); + + if (newLogs.length > 0) { + const batch = newLogs.filter(Boolean).map(processLog).join('\r\n'); + + if (batch) { + write(batch + '\r\n'); + scrollToBottom(); + } + } + } else { + clear(); + + const batch = logs.filter(Boolean).map(processLog).join('\r\n'); + + if (batch) { + write(batch + '\r\n'); + } + + scrollToBottom(); + } + + lastLogIndexRef.current = logs.length; + prevLogsLengthRef.current = logs.length; + }, [logs, isReady, write, clear, scrollToBottom]); + + return ( +
+ ); +}; + +Terminal.displayName = 'Terminal'; + +export type { TerminalRef }; +export default Terminal; diff --git a/frontend/src/components/shared/terminal/use-terminal-search.ts b/frontend/src/components/shared/terminal/use-terminal-search.ts new file mode 100644 index 00000000..e3497e61 --- /dev/null +++ b/frontend/src/components/shared/terminal/use-terminal-search.ts @@ -0,0 +1,76 @@ +import type { SearchAddon } from '@xterm/addon-search'; + +import { useCallback, useEffect } from 'react'; + +import { Log } from '@/lib/log'; + +import { getSearchDecorations, isDarkMode } from './terminal-config'; + +interface UseTerminalSearchResult { + findNext: () => void; + findPrevious: () => void; +} + +export function useTerminalSearch( + searchAddon: null | SearchAddon, + isReady: boolean, + searchValue: string | undefined, + theme: 'dark' | 'light' | 'system', +): UseTerminalSearchResult { + useEffect(() => { + if (!searchAddon || !isReady) { + return; + } + + try { + const trimmed = searchValue?.trim(); + + if (trimmed) { + searchAddon.findNext(trimmed, buildSearchOptions(isDarkMode(theme))); + } else { + searchAddon.clearDecorations(); + } + } catch (error: unknown) { + Log.error('Terminal search failed:', error); + } + }, [searchAddon, isReady, searchValue, theme]); + + const findNext = useCallback(() => { + const trimmed = searchValue?.trim(); + + if (!searchAddon || !trimmed) { + return; + } + + try { + searchAddon.findNext(trimmed, buildSearchOptions(isDarkMode(theme))); + } catch (error: unknown) { + Log.error('Terminal findNext failed:', error); + } + }, [searchAddon, searchValue, theme]); + + const findPrevious = useCallback(() => { + const trimmed = searchValue?.trim(); + + if (!searchAddon || !trimmed) { + return; + } + + try { + searchAddon.findPrevious(trimmed, buildSearchOptions(isDarkMode(theme))); + } catch (error: unknown) { + Log.error('Terminal findPrevious failed:', error); + } + }, [searchAddon, searchValue, theme]); + + return { findNext, findPrevious }; +} + +function buildSearchOptions(isDark: boolean) { + return { + caseSensitive: false, + decorations: getSearchDecorations(isDark), + regex: false, + wholeWord: false, + } as const; +} diff --git a/frontend/src/components/shared/terminal/use-xterm.ts b/frontend/src/components/shared/terminal/use-xterm.ts new file mode 100644 index 00000000..3dec839b --- /dev/null +++ b/frontend/src/components/shared/terminal/use-xterm.ts @@ -0,0 +1,328 @@ +import '@xterm/xterm/css/xterm.css'; +import type { ILinkHandler } from '@xterm/xterm'; + +import { FitAddon } from '@xterm/addon-fit'; +import { SearchAddon } from '@xterm/addon-search'; +import { Unicode11Addon } from '@xterm/addon-unicode11'; +import { WebLinksAddon } from '@xterm/addon-web-links'; +import { WebglAddon } from '@xterm/addon-webgl'; +import { Terminal } from '@xterm/xterm'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { Log } from '@/lib/log'; +import { isMac } from '@/lib/utils/platform'; + +import { getTerminalTheme, isDarkMode, TERMINAL_OPTIONS } from './terminal-config'; + +const FLOW_CONTROL_CHUNK_SIZE = 64 * 1024; +const DEBOUNCE_DELAY = 150; +const TOOLTIP_CLASS = 'xterm-link-tooltip'; + +export interface UseXtermResult { + clear: () => void; + containerRef: React.RefObject; + isReady: boolean; + scrollToBottom: () => void; + searchAddon: null | SearchAddon; + write: (data: string) => void; +} + +/** + * Manages the full xterm.js lifecycle: creation, addon loading, + * resize handling, WebGL fallback, theme sync, and cleanup. + * + * Implements chunked flow control for large writes per + * https://xtermjs.org/docs/guides/flowcontrol/ + * + * Requires Ctrl+Click (Cmd+Click on Mac) to open links per + * https://xtermjs.org/docs/guides/link-handling/ + */ +export function useXterm({ theme }: { theme: 'dark' | 'light' | 'system' }): UseXtermResult { + const containerRef = useRef(null); + const terminalRef = useRef(null); + const themeRef = useRef(theme); + const [isReady, setIsReady] = useState(false); + const [searchAddon, setSearchAddon] = useState(null); + + useEffect(() => { + themeRef.current = theme; + }, [theme]); + + const write = useCallback((data: string) => { + const terminal = terminalRef.current; + + if (!terminal) { + return; + } + + try { + writeWithFlowControl(terminal, data); + } catch (error: unknown) { + Log.error('Terminal write failed:', error); + } + }, []); + + const clear = useCallback(() => { + try { + terminalRef.current?.clear(); + } catch (error: unknown) { + Log.error('Terminal clear failed:', error); + } + }, []); + + const scrollToBottom = useCallback(() => { + try { + terminalRef.current?.scrollToBottom(); + } catch (error: unknown) { + Log.error('Terminal scrollToBottom failed:', error); + } + }, []); + + useEffect(() => { + const container = containerRef.current; + + if (!container) { + return; + } + + let mounted = true; + + const terminal = new Terminal({ + ...TERMINAL_OPTIONS, + theme: getTerminalTheme(isDarkMode(themeRef.current)), + }); + + const fitAddon = new FitAddon(); + const search = new SearchAddon(); + const unicodeAddon = new Unicode11Addon(); + + const mac = isMac(); + + const openLink = (event: MouseEvent, uri: string) => { + if (mac ? event.metaKey : event.ctrlKey) { + window.open(uri, '_blank', 'noopener,noreferrer'); + } + }; + + const linkHandler: ILinkHandler = { + activate: (event, text) => openLink(event, text), + allowNonHttpProtocols: true, + hover: (event, text) => showLinkTooltip(container, event, text, mac), + leave: () => removeLinkTooltip(container), + }; + + terminal.options.linkHandler = linkHandler; + + const webLinksAddon = new WebLinksAddon(openLink, { + hover: (event, text) => showLinkTooltip(container, event, text, mac), + leave: () => removeLinkTooltip(container), + }); + + terminal.loadAddon(fitAddon); + terminal.loadAddon(search); + terminal.loadAddon(unicodeAddon); + terminal.unicode.activeVersion = '11'; + terminal.loadAddon(webLinksAddon); + + const disposables: Array<{ dispose: () => void }> = [unicodeAddon, webLinksAddon]; + + try { + const webglAddon = new WebglAddon(); + terminal.loadAddon(webglAddon); + disposables.push(webglAddon); + disposables.push( + webglAddon.onContextLoss(() => { + try { + webglAddon.dispose(); + } catch { + /* ignore */ + } + }), + ); + } catch { + /* WebGL not available — canvas renderer is used as fallback */ + } + + const safeFit = () => { + try { + if (container.offsetHeight > 0) { + fitAddon.fit(); + } + } catch (error: unknown) { + Log.error('Terminal fit failed:', error); + } + }; + + let debounceTimer: null | ReturnType = null; + const resizeObserver = new ResizeObserver(() => { + if (debounceTimer) { + clearTimeout(debounceTimer); + } + + debounceTimer = setTimeout(safeFit, DEBOUNCE_DELAY); + }); + + terminalRef.current = terminal; + + const rafId = requestAnimationFrame(() => { + if (!mounted) { + return; + } + + try { + terminal.open(container); + resizeObserver.observe(container); + safeFit(); + setSearchAddon(search); + setIsReady(true); + } catch (error: unknown) { + Log.error('Failed to open terminal:', error); + } + }); + + return () => { + mounted = false; + cancelAnimationFrame(rafId); + + if (debounceTimer) { + clearTimeout(debounceTimer); + } + + removeLinkTooltip(container); + resizeObserver.disconnect(); + + for (const d of disposables) { + try { + d.dispose(); + } catch { + /* ignore */ + } + } + + try { + search.dispose(); + } catch { + /* ignore */ + } + + try { + fitAddon.dispose(); + } catch { + /* ignore */ + } + + try { + terminal.dispose(); + } catch { + /* ignore */ + } + + terminalRef.current = null; + setSearchAddon(null); + setIsReady(false); + }; + }, []); + + useEffect(() => { + const terminal = terminalRef.current; + + if (!terminal) { + return; + } + + const applyTheme = () => { + try { + terminal.options.theme = getTerminalTheme(isDarkMode(theme)); + } catch (error: unknown) { + Log.error('Terminal theme update failed:', error); + } + }; + + applyTheme(); + + if (theme === 'system') { + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + const handler = () => applyTheme(); + mediaQuery.addEventListener('change', handler); + + return () => { + mediaQuery.removeEventListener('change', handler); + }; + } + }, [theme]); + + return { clear, containerRef, isReady, scrollToBottom, searchAddon, write }; +} + +function removeLinkTooltip(container: HTMLElement): void { + const existing = container.querySelector(`.${TOOLTIP_CLASS}`); + + if (existing) { + existing.remove(); + } +} + +function showLinkTooltip(container: HTMLElement, event: MouseEvent, uri: string, mac: boolean): void { + removeLinkTooltip(container); + + const tooltip = document.createElement('div'); + tooltip.className = `${TOOLTIP_CLASS} xterm-hover`; + tooltip.style.cssText = + 'position:absolute;z-index:10;padding:4px 8px;max-width:80%;font-size:12px;' + + 'line-height:1.4;border-radius:4px;pointer-events:none;word-break:break-all;' + + 'background:var(--popover);color:var(--popover-foreground);' + + 'border:1px solid var(--border);box-shadow:0 2px 8px rgba(0,0,0,.15)'; + + const urlSpan = document.createElement('span'); + urlSpan.textContent = uri; + tooltip.appendChild(urlSpan); + + const hint = document.createElement('div'); + hint.style.cssText = 'opacity:0.6;font-size:11px;margin-top:2px'; + hint.textContent = `${mac ? 'Cmd' : 'Ctrl'}+Click to open`; + tooltip.appendChild(hint); + + container.style.position = 'relative'; + container.appendChild(tooltip); + + const rect = container.getBoundingClientRect(); + const x = event.clientX - rect.left; + let y = event.clientY - rect.top + 20; + + tooltip.style.left = `${Math.min(x, rect.width - tooltip.offsetWidth - 8)}px`; + + if (y + tooltip.offsetHeight > rect.height) { + y = event.clientY - rect.top - tooltip.offsetHeight - 8; + } + + tooltip.style.top = `${Math.max(0, y)}px`; +} + +/** + * Writes data to the terminal with chunked flow control. + * For large payloads, splits into chunks and uses xterm's write callback + * to avoid overwhelming the parser and blocking the UI thread. + * See: https://xtermjs.org/docs/guides/flowcontrol/ + */ +function writeWithFlowControl(terminal: Terminal, data: string): void { + if (data.length <= FLOW_CONTROL_CHUNK_SIZE) { + terminal.write(data); + + return; + } + + let offset = 0; + + const writeNext = () => { + const chunk = data.slice(offset, offset + FLOW_CONTROL_CHUNK_SIZE); + offset += FLOW_CONTROL_CHUNK_SIZE; + + if (offset < data.length) { + terminal.write(chunk, writeNext); + } else { + terminal.write(chunk); + } + }; + + writeNext(); +} From f49d9cba428e1905b610229e0eb1c3520fa8211d Mon Sep 17 00:00:00 2001 From: Sergey Kozyrenko Date: Thu, 9 Apr 2026 02:24:58 +0700 Subject: [PATCH 2/2] feat: update terminal --- .../terminal/terminal-sanitizer.test.ts | 208 +++++++++++++----- .../shared/terminal/terminal-sanitizer.ts | 41 +++- .../components/shared/terminal/use-xterm.ts | 29 ++- 3 files changed, 212 insertions(+), 66 deletions(-) diff --git a/frontend/src/components/shared/terminal/terminal-sanitizer.test.ts b/frontend/src/components/shared/terminal/terminal-sanitizer.test.ts index 47b1a82c..90ced26e 100644 --- a/frontend/src/components/shared/terminal/terminal-sanitizer.test.ts +++ b/frontend/src/components/shared/terminal/terminal-sanitizer.test.ts @@ -117,52 +117,55 @@ describe('sanitizeTerminalOutput', () => { }); }); - describe('CSI non-SGR — blocked', () => { - it('strips ED: erase display', () => { - expect(hasEsc(processLog('a\x1b[2Jb'))).toBe(false); + describe('CSI non-SGR — blocked with body consumed', () => { + it('consumes ED: erase display', () => { + expect(processLog('a\x1b[2Jb')).toBe('a.b'); }); - it('strips ED: erase scrollback', () => { - expect(hasEsc(processLog('a\x1b[3Jb'))).toBe(false); + it('consumes ED: erase scrollback', () => { + expect(processLog('a\x1b[3Jb')).toBe('a.b'); }); - it('strips CUP: cursor home', () => { - expect(hasEsc(processLog('a\x1b[Hb'))).toBe(false); + it('consumes CUP: cursor home', () => { + expect(processLog('a\x1b[Hb')).toBe('a.b'); }); - it('strips SU: scroll up', () => { - expect(hasEsc(processLog('a\x1b[999Sb'))).toBe(false); + it('consumes SU: scroll up', () => { + expect(processLog('a\x1b[999Sb')).toBe('a.b'); }); - it('strips SD: scroll down', () => { - expect(hasEsc(processLog('a\x1b[999Tb'))).toBe(false); + it('consumes SD: scroll down', () => { + expect(processLog('a\x1b[999Tb')).toBe('a.b'); }); - it('strips DECSET: alternate buffer', () => { - expect(hasEsc(processLog('a\x1b[?1049hb'))).toBe(false); + it('consumes DECSET: alternate buffer', () => { + expect(processLog('a\x1b[?1049hb')).toBe('a.b'); }); - it('strips DECSET: hide cursor', () => { - expect(hasEsc(processLog('a\x1b[?25lb'))).toBe(false); + it('consumes DECSET: hide cursor', () => { + expect(processLog('a\x1b[?25lb')).toBe('a.b'); }); - it('strips DL: delete lines', () => { - expect(hasEsc(processLog('a\x1b[10Mb'))).toBe(false); + it('consumes DL: delete lines', () => { + expect(processLog('a\x1b[10Mb')).toBe('a.b'); }); - it('strips IL: insert lines', () => { - expect(hasEsc(processLog('a\x1b[10Lb'))).toBe(false); + it('consumes IL: insert lines', () => { + expect(processLog('a\x1b[10Lb')).toBe('a.b'); }); - it('strips ICH: insert characters', () => { - expect(hasEsc(processLog('a\x1b[10@b'))).toBe(false); + it('consumes ICH: insert characters', () => { + expect(processLog('a\x1b[10@b')).toBe('a.b'); }); - it('preserves surrounding text when stripping CSI', () => { - const out = processLog('before\x1b[2Jafter'); + it('preserves surrounding text when consuming CSI', () => { + expect(processLog('before\x1b[2Jafter')).toBe('before.after'); + }); - expect(out).toContain('before'); - expect(out).toContain('after'); + it('does not consume invalid CSI (no final byte)', () => { + const out = processLog('\x1b['); + + expect(out).not.toContain('\x1b'); }); }); @@ -235,79 +238,85 @@ describe('sanitizeTerminalOutput', () => { }); describe('OSC 8 hyperlinks — dangerous protocols blocked', () => { - it('strips javascript: URI', () => { + it('consumes javascript: URI with body', () => { const input = '\x1b]8;;javascript:alert(1)\x07Click\x1b]8;;\x07'; const out = processLog(input); - expect(out).not.toContain('\x1b]8;;javascript:'); + expect(out).not.toContain('javascript:'); + expect(out).toContain('Click'); + expect(out).toContain('\x1b]8;;\x07'); }); - it('strips data: URI', () => { + it('consumes data: URI with body', () => { const input = '\x1b]8;;data:text/html,

XSS

\x07Click\x1b]8;;\x07'; const out = processLog(input); - expect(out).not.toContain('\x1b]8;;data:'); + expect(out).not.toContain('data:'); }); - it('strips ftp: URI', () => { + it('consumes ftp: URI with body', () => { const input = '\x1b]8;;ftp://evil.com/shell\x07Click\x1b]8;;\x07'; const out = processLog(input); - expect(out).not.toContain('\x1b]8;;ftp:'); + expect(out).not.toContain('ftp:'); }); - it('strips file: URI', () => { + it('consumes file: URI with body', () => { const input = '\x1b]8;;file:///etc/passwd\x07Click\x1b]8;;\x07'; const out = processLog(input); - expect(out).not.toContain('\x1b]8;;file:'); + expect(out).not.toContain('file:'); }); - it('preserves safe close tag even when open tag is stripped', () => { + it('preserves safe close tag even when open tag is consumed', () => { const input = '\x1b]8;;javascript:x\x07Click\x1b]8;;\x07'; const out = processLog(input); expect(out).toContain('\x1b]8;;\x07'); }); - it('strips OSC 8 with missing second semicolon', () => { + it('consumes OSC 8 with missing second semicolon', () => { const input = '\x1b]8;https://example.com\x07Click\x1b]8;;\x07'; const out = processLog(input); - expect(out).not.toContain('\x1b]8;https:'); + expect(out).not.toContain('8;https:'); }); - it('strips OSC 8 with unknown protocol', () => { + it('consumes OSC 8 with unknown protocol', () => { const input = '\x1b]8;;custom://something\x07Click\x1b]8;;\x07'; const out = processLog(input); - expect(out).not.toContain('\x1b]8;;custom:'); + expect(out).not.toContain('custom:'); }); }); - describe('OSC non-hyperlink — blocked', () => { - it('strips OSC 0: window title', () => { - expect(hasEsc(processLog('\x1b]0;HACKED\x07'))).toBe(false); + describe('OSC non-hyperlink — blocked with body consumed', () => { + it('consumes OSC 0: window title', () => { + expect(processLog('\x1b]0;HACKED\x07')).toBe('.'); }); - it('strips OSC 2: window title', () => { - expect(hasEsc(processLog('\x1b]2;HACKED\x07'))).toBe(false); + it('consumes OSC 2: window title', () => { + expect(processLog('\x1b]2;HACKED\x07')).toBe('.'); }); - it('strips OSC 10: foreground color', () => { - expect(hasEsc(processLog('\x1b]10;#ff0000\x07'))).toBe(false); + it('consumes OSC 10: foreground color', () => { + expect(processLog('\x1b]10;#ff0000\x07')).toBe('.'); }); - it('strips OSC 11: background color', () => { - expect(hasEsc(processLog('\x1b]11;#00ff00\x07'))).toBe(false); + it('consumes OSC 11: background color', () => { + expect(processLog('\x1b]11;#00ff00\x07')).toBe('.'); }); - it('strips OSC 12: cursor color', () => { - expect(hasEsc(processLog('\x1b]12;#0000ff\x07'))).toBe(false); + it('consumes OSC 12: cursor color', () => { + expect(processLog('\x1b]12;#0000ff\x07')).toBe('.'); }); - it('strips OSC 4: palette color', () => { - expect(hasEsc(processLog('\x1b]4;1;#ff0000\x07'))).toBe(false); + it('consumes OSC 4: palette color', () => { + expect(processLog('\x1b]4;1;#ff0000\x07')).toBe('.'); + }); + + it('preserves surrounding text when consuming OSC', () => { + expect(processLog('before\x1b]0;TITLE\x07after')).toBe('before.after'); }); }); @@ -596,6 +605,103 @@ describe('sanitizeTerminalOutput', () => { }); }); + describe('bidi override characters — stripped', () => { + it('strips RTL override U+202E', () => { + expect(processLog('hello\u202Eworld')).toBe('hello.world'); + }); + + it('strips LTR mark U+200E', () => { + expect(processLog('a\u200Eb')).toBe('a.b'); + }); + + it('strips RTL mark U+200F', () => { + expect(processLog('a\u200Fb')).toBe('a.b'); + }); + + it('strips LTR embedding U+202A', () => { + expect(processLog('a\u202Ab')).toBe('a.b'); + }); + + it('strips first-strong isolate U+2068', () => { + expect(processLog('a\u2068b')).toBe('a.b'); + }); + + it('strips pop directional isolate U+2069', () => { + expect(processLog('a\u2069b')).toBe('a.b'); + }); + + it('strips LTR isolate U+2066', () => { + expect(processLog('a\u2066b')).toBe('a.b'); + }); + + it('strips multiple bidi controls in sequence', () => { + expect(processLog('a\u202E\u202D\u200Fb')).toBe('a...b'); + }); + + it('preserves normal Unicode alongside bidi stripping', () => { + expect(processLog('Привет\u202Eмир')).toBe('Привет.мир'); + }); + + it('preserves SGR alongside bidi stripping', () => { + const out = processLog('\x1b[31m\u202Etext\x1b[0m'); + + expect(out).toBe('\x1b[31m.text\x1b[0m'); + }); + }); + + describe('needsSanitization — bidi detection', () => { + it('returns true for RTL override U+202E', () => { + expect(needsSanitization('hello\u202Eworld')).toBe(true); + }); + + it('returns true for LTR mark U+200E', () => { + expect(needsSanitization('text\u200E')).toBe(true); + }); + + it('returns true for RTL mark U+200F', () => { + expect(needsSanitization('text\u200F')).toBe(true); + }); + + it('returns true for isolate U+2066', () => { + expect(needsSanitization('text\u2066')).toBe(true); + }); + + it('returns false for non-bidi Unicode above 0xA0', () => { + expect(needsSanitization('\u2010\u2014\u2026')).toBe(false); + }); + }); + + describe('OSC 8 with Unicode URI', () => { + it('preserves OSC 8 link with Cyrillic path', () => { + const input = '\x1b]8;;https://example.com/путь\x07Click\x1b]8;;\x07'; + const out = processLog(input); + + expect(out).toContain('https://example.com/путь'); + expect(out).toContain('\x1b]8;;https://example.com/путь\x07'); + }); + + it('preserves OSC 8 link with CJK path', () => { + const input = '\x1b]8;;https://example.com/文档\x07Link\x1b]8;;\x07'; + const out = processLog(input); + + expect(out).toContain('https://example.com/文档'); + }); + + it('rejects OSC with C1 control in content', () => { + const input = '\x1b]8;;\x90https://x\x07Click\x1b]8;;\x07'; + const out = processLog(input); + + expect(out).not.toContain('\x1b]8;;\x90'); + }); + + it('rejects OSC with DEL in content', () => { + const input = '\x1b]8;;\x7fhttps://x\x07Click\x1b]8;;\x07'; + const out = processLog(input); + + expect(out).not.toContain('\x1b]8;;\x7f'); + }); + }); + describe('performance', () => { it('processes 1M chars in under 500ms', () => { const input = '\x1b[31m' + 'A'.repeat(100_000) + '\x1b[0m'; diff --git a/frontend/src/components/shared/terminal/terminal-sanitizer.ts b/frontend/src/components/shared/terminal/terminal-sanitizer.ts index 5922090b..f7c965d9 100644 --- a/frontend/src/components/shared/terminal/terminal-sanitizer.ts +++ b/frontend/src/components/shared/terminal/terminal-sanitizer.ts @@ -4,7 +4,7 @@ const MAX_CSI_LENGTH = 256; const MAX_OSC_LENGTH = 2048; const MAX_STRING_SEQUENCE_LENGTH = 100_000; -const SAFE_OSC8_PROTOCOLS = ['http://', 'https://', 'mailto:', 'ssh://', 'telnet://']; +export const SAFE_PROTOCOLS = ['http://', 'https://', 'mailto:', 'ssh://', 'telnet://']; /** * Quick scan to determine if a string needs full sanitization. @@ -45,6 +45,10 @@ export function needsSanitization(input: string): boolean { return true; } + if (isBidiControl(code)) { + return true; + } + continue; } @@ -102,7 +106,7 @@ export function sanitizeTerminalOutput(input: string): string { continue; } - // Valid Unicode (>= 0xA0, excluding lone surrogates and U+FFFD) + // Valid Unicode (>= 0xA0, excluding lone surrogates, U+FFFD, and bidi controls) if (code >= 0xa0) { // Valid surrogate pair (emoji, supplementary chars) — skip both units if (code >= 0xd800 && code <= 0xdbff) { @@ -117,13 +121,13 @@ export function sanitizeTerminalOutput(input: string): string { } // Lone/invalid surrogate — falls through to replacement below - } else if (code !== 0xfffd && !(code >= 0xdc00 && code <= 0xdfff)) { + } else if (code !== 0xfffd && !(code >= 0xdc00 && code <= 0xdfff) && !isBidiControl(code)) { i++; continue; } - // Lone surrogates and U+FFFD fall through to replacement below + // Lone surrogates, U+FFFD, and bidi controls fall through to replacement below } // We hit a character that needs handling — flush the safe range @@ -155,6 +159,10 @@ export function sanitizeTerminalOutput(input: string): string { continue; } + if (parsed.end !== -1) { + i = parsed.end; + } + result.push('.'); safeStart = i; @@ -176,6 +184,8 @@ export function sanitizeTerminalOutput(input: string): string { continue; } + + i = end; } result.push('.'); @@ -214,6 +224,12 @@ export function sanitizeTerminalOutput(input: string): string { return result.join(''); } +function isBidiControl(code: number): boolean { + return ( + (code >= 0x200e && code <= 0x200f) || (code >= 0x202a && code <= 0x202e) || (code >= 0x2066 && code <= 0x2069) + ); +} + /** * Heuristic detection of binary content in a JS string. * Binary data (e.g. JPEG) interpreted as UTF-16 produces null bytes, @@ -301,7 +317,7 @@ function isOscSafe(content: string): boolean { // Validate protocol against whitelist (blocks javascript:, data:, ftp:, file:, etc.) const uriLower = uri.toLowerCase(); - return SAFE_OSC8_PROTOCOLS.some((protocol) => uriLower.startsWith(protocol)); + return SAFE_PROTOCOLS.some((protocol) => uriLower.startsWith(protocol)); } /** @@ -313,10 +329,7 @@ function isOscSafe(content: string): boolean { * - Intermediate bytes: 0x20-0x2F (space, !, ", etc.) * - Final byte: 0x40-0x7E (letters — 'm' for SGR, 'H' for CUP, etc.) */ -function parseCsiSequence( - input: string, - start: number, -): { end: number; final: string } { +function parseCsiSequence(input: string, start: number): { end: number; final: string } { let i = start; while (i < input.length && i - start < MAX_CSI_LENGTH) { @@ -341,7 +354,9 @@ function parseCsiSequence( * Returns end index (after terminator), or -1 if invalid. * * OSC format: ESC ] content BEL or ESC ] content ESC \ - * Only ASCII printables and BEL are allowed in content per xterm.js parser. + * Allows ASCII printables (0x20-0x7E) and Unicode >= 0xA0 per xterm.js parser + * (which accepts "any codepoint greater than C1 as printable"). + * Blocks C0 controls (except BEL as terminator), DEL, and C1 controls (0x80-0x9F). */ function parseOscSequence(input: string, start: number): number { let i = start; @@ -357,7 +372,11 @@ function parseOscSequence(input: string, start: number): number { return i + 2; } - if (code > 0x7e || (code < 0x20 && code !== 0x07)) { + if (code < 0x20 && code !== 0x07) { + return -1; + } + + if (code === 0x7f || (code >= 0x80 && code <= 0x9f)) { return -1; } diff --git a/frontend/src/components/shared/terminal/use-xterm.ts b/frontend/src/components/shared/terminal/use-xterm.ts index 3dec839b..5153a97f 100644 --- a/frontend/src/components/shared/terminal/use-xterm.ts +++ b/frontend/src/components/shared/terminal/use-xterm.ts @@ -13,10 +13,11 @@ import { Log } from '@/lib/log'; import { isMac } from '@/lib/utils/platform'; import { getTerminalTheme, isDarkMode, TERMINAL_OPTIONS } from './terminal-config'; +import { SAFE_PROTOCOLS } from './terminal-sanitizer'; const FLOW_CONTROL_CHUNK_SIZE = 64 * 1024; const DEBOUNCE_DELAY = 150; -const TOOLTIP_CLASS = 'xterm-link-tooltip'; +const TOOLTIP_CLASS = 'terminal-link-tooltip'; export interface UseXtermResult { clear: () => void; @@ -99,7 +100,12 @@ export function useXterm({ theme }: { theme: 'dark' | 'light' | 'system' }): Use const mac = isMac(); const openLink = (event: MouseEvent, uri: string) => { - if (mac ? event.metaKey : event.ctrlKey) { + const uriLower = uri.toLowerCase(); + + if ( + (mac ? event.metaKey : event.ctrlKey) && + SAFE_PROTOCOLS.some((p) => uriLower.startsWith(p)) + ) { window.open(uri, '_blank', 'noopener,noreferrer'); } }; @@ -302,6 +308,11 @@ function showLinkTooltip(container: HTMLElement, event: MouseEvent, uri: string, * Writes data to the terminal with chunked flow control. * For large payloads, splits into chunks and uses xterm's write callback * to avoid overwhelming the parser and blocking the UI thread. + * + * Chunk boundaries are adjusted to avoid splitting UTF-16 surrogate pairs: + * if the last code unit of a chunk is a high surrogate (0xD800-0xDBFF), + * the boundary is moved back by one so the pair stays intact. + * * See: https://xtermjs.org/docs/guides/flowcontrol/ */ function writeWithFlowControl(terminal: Terminal, data: string): void { @@ -314,8 +325,18 @@ function writeWithFlowControl(terminal: Terminal, data: string): void { let offset = 0; const writeNext = () => { - const chunk = data.slice(offset, offset + FLOW_CONTROL_CHUNK_SIZE); - offset += FLOW_CONTROL_CHUNK_SIZE; + let end = Math.min(offset + FLOW_CONTROL_CHUNK_SIZE, data.length); + + if (end < data.length) { + const code = data.charCodeAt(end - 1); + + if (code >= 0xd800 && code <= 0xdbff) { + end--; + } + } + + const chunk = data.slice(offset, end); + offset = end; if (offset < data.length) { terminal.write(chunk, writeNext);