diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index 1f5df46eb2a4..a24344a2e7b8 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -6,6 +6,7 @@ import { composerFill, composerSurfaceGlass } from '@/components/chat/composer-d import { Button } from '@/components/ui/button' import { useI18n } from '@/i18n' import { chatMessageText } from '@/lib/chat-messages' +import { sanitizeComposerInput } from '@/lib/composer-input-sanitize' import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images' import { triggerHaptic } from '@/lib/haptics' import { cn } from '@/lib/utils' @@ -267,7 +268,7 @@ export function ChatBar({ normalizeComposerEditorDom(editor) - const nextDraft = composerPlainText(editor) + const nextDraft = sanitizeComposerInput(composerPlainText(editor)) if (nextDraft !== draftRef.current) { draftRef.current = nextDraft @@ -332,7 +333,7 @@ export function ChatBar({ // blank lines (common when selecting from terminals, code blocks, web pages) // doesn't dump multiline padding into the composer. Internal newlines are // preserved — only the edges are cleaned up. - const pastedText = event.clipboardData.getData('text').trim() + const pastedText = sanitizeComposerInput(event.clipboardData.getData('text').trim()) if (!pastedText) { event.preventDefault() diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts index 89f978cd69f8..0c1b62eff78f 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts @@ -6,6 +6,7 @@ import { PROMPT_SUBMIT_REQUEST_TIMEOUT_MS, transcribeAudio } from '@/hermes' import { useI18n } from '@/i18n' import { stripAnsi } from '@/lib/ansi' import { branchGroupForUser, type ChatMessage, chatMessageText, textPart } from '@/lib/chat-messages' +import { sanitizeComposerInput } from '@/lib/composer-input-sanitize' import { pathLabel, SLASH_COMMAND_RE } from '@/lib/chat-runtime' import { triggerHaptic } from '@/lib/haptics' import { setMutableRef } from '@/lib/mutable-ref' @@ -471,7 +472,7 @@ export function usePromptActions({ const submitText = useCallback( async (rawText: string, options?: SubmitTextOptions) => { - const visibleText = rawText.trim() + const visibleText = sanitizeComposerInput(rawText).trim() const attachments = options?.attachments ?? $composerAttachments.get() if (!attachments.length && SLASH_COMMAND_RE.test(visibleText)) { @@ -596,7 +597,7 @@ export function usePromptActions({ // window) so the caller can fall back to queueing the words for the next turn. const steerPrompt = useCallback( async (rawText: string): Promise => { - const text = rawText.trim() + const text = sanitizeComposerInput(rawText).trim() const sessionId = activeSessionId || activeSessionIdRef.current if (!text || !sessionId) { diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts index b1eaa4966bf2..ae94d865de62 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts @@ -3,6 +3,7 @@ import { type MutableRefObject, useCallback } from 'react' import { PROMPT_SUBMIT_REQUEST_TIMEOUT_MS } from '@/hermes' import type { Translations } from '@/i18n' import { type ChatMessage, textPart } from '@/lib/chat-messages' +import { sanitizeComposerInput } from '@/lib/composer-input-sanitize' import { optimisticAttachmentRef } from '@/lib/chat-runtime' import { setMutableRef } from '@/lib/mutable-ref' import { @@ -67,7 +68,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { return useCallback( async (rawText: string, options?: SubmitTextOptions) => { - const visibleText = rawText.trim() + const visibleText = sanitizeComposerInput(rawText).trim() const usingComposerAttachments = !options?.attachments // Drop undefined/null holes a session switch or draft restore can leave in diff --git a/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx b/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx index b292f3f90bb8..70a5472e585c 100644 --- a/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx @@ -57,6 +57,7 @@ import { Codicon } from '@/components/ui/codicon' import type { HermesGateway } from '@/hermes' import { useI18n } from '@/i18n' import { attachmentDisplayText, attachmentId, pathLabel } from '@/lib/chat-runtime' +import { sanitizeComposerInput } from '@/lib/composer-input-sanitize' import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images' import { triggerHaptic } from '@/lib/haptics' import { Loader2Icon } from '@/lib/icons' @@ -188,7 +189,7 @@ export const UserEditComposer: FC = ({ cwd, gateway, sess const syncDraftFromEditor = useCallback( (editor: HTMLDivElement) => { - const nextDraft = composerPlainText(editor) + const nextDraft = sanitizeComposerInput(composerPlainText(editor)) if (nextDraft !== draftRef.current) { draftRef.current = nextDraft @@ -477,7 +478,7 @@ export const UserEditComposer: FC = ({ cwd, gateway, sess } const handlePaste = (event: ClipboardEvent) => { - const pastedText = event.clipboardData.getData('text') + const pastedText = sanitizeComposerInput(event.clipboardData.getData('text')) if (!pastedText || DATA_IMAGE_URL_RE.test(pastedText.trim())) { event.preventDefault() diff --git a/apps/desktop/src/lib/composer-input-sanitize.ts b/apps/desktop/src/lib/composer-input-sanitize.ts new file mode 100644 index 000000000000..39f392681477 --- /dev/null +++ b/apps/desktop/src/lib/composer-input-sanitize.ts @@ -0,0 +1,71 @@ +/** + * Strip terminal bracketed-paste leaks and repeated artifact tails from composer + * text before it is shown in the UI or sent to the gateway. + * + * Mirrors hermes_cli/input_sanitize.py (CLI/TUI gateway defensive path). + */ + +const BRACKETED_PASTE_BOUNDARY_START = /(^|[\s\n>:\]\)])\[200~/g +const BRACKETED_PASTE_BOUNDARY_END = /\[201~(?=$|[\s\n<[():;.,!?])/g +const BRACKETED_PASTE_DEGRADED_START = /(^|[\s\n>:\]\)])00~/g +const BRACKETED_PASTE_DEGRADED_END = /01~(?=$|[\s\n<[():;.,!?])/g + +const DESKTOP_PASTE_ARTIFACT = '~[[e' + +/** Strip leaked bracketed-paste wrapper markers from user-visible text. */ +export function stripLeakedBracketedPasteWrappers(text: string): string { + if (!text) { + return text + } + + let cleaned = text + .replace(/\x1b\[200~/g, '') + .replace(/\x1b\[201~/g, '') + .replace(/\^\[\[200~/g, '') + .replace(/\^\[\[201~/g, '') + + cleaned = cleaned.replace(BRACKETED_PASTE_BOUNDARY_START, '$1') + cleaned = cleaned.replace(BRACKETED_PASTE_BOUNDARY_END, '') + cleaned = cleaned.replace(BRACKETED_PASTE_DEGRADED_START, '$1') + cleaned = cleaned.replace(BRACKETED_PASTE_DEGRADED_END, '') + + return cleaned +} + +/** Drop a trailing run of the desktop ~[[e corruption signature (#62557). */ +export function collapseRepeatedInputArtifacts(text: string, minRepeats = 4): string { + if (!text) { + return text + } + + const marker = DESKTOP_PASTE_ARTIFACT + let index = text.length + let repeatCount = 0 + + while (index >= marker.length && text.slice(index - marker.length, index) === marker) { + repeatCount += 1 + index -= marker.length + } + + if (repeatCount < minRepeats) { + return text + } + + let start = index + if (start >= 2 && text.slice(start - 2, start) === '[e') { + start -= 2 + } else if (start >= 1 && text[start - 1] === '[') { + start -= 1 + } + + return text.slice(0, start) +} + +/** Normalize composer text before submit or draft persistence. */ +export function sanitizeComposerInput(text: string): string { + if (!text) { + return text + } + + return collapseRepeatedInputArtifacts(stripLeakedBracketedPasteWrappers(text)) +} diff --git a/cli.py b/cli.py index 6b2f00cf936a..8a8fdf6f5ff5 100644 --- a/cli.py +++ b/cli.py @@ -2995,29 +2995,9 @@ def _should_auto_attach_clipboard_image_on_paste(pasted_text: str) -> bool: def _strip_leaked_bracketed_paste_wrappers(text: str) -> str: - """Strip leaked bracketed-paste wrapper markers from user-visible text. + from hermes_cli.input_sanitize import strip_leaked_bracketed_paste_wrappers - Defensive normalization for cases where terminal/prompt_toolkit parsing - fails and bracketed-paste markers end up in the buffer as literal text. - - We strip canonical wrappers unconditionally and also handle degraded - visible forms like ``[200~`` / ``[201~`` and ``00~`` / ``01~`` when they - look like wrapper boundaries, not arbitrary user content. - """ - if not text: - return text - - text = ( - text.replace("\x1b[200~", "") - .replace("\x1b[201~", "") - .replace("^[[200~", "") - .replace("^[[201~", "") - ) - text = re.sub(r"(^|[\s\n>:\]\)])\[200~", r"\1", text) - text = re.sub(r"\[201~(?=$|[\s\n<\[\(\):;.,!?])", "", text) - text = re.sub(r"(^|[\s\n>:\]\)])00~", r"\1", text) - text = re.sub(r"01~(?=$|[\s\n<\[\(\):;.,!?])", "", text) - return text + return strip_leaked_bracketed_paste_wrappers(text) def _apply_bracketed_paste_timeout_patch() -> None: diff --git a/hermes_cli/input_sanitize.py b/hermes_cli/input_sanitize.py new file mode 100644 index 000000000000..90ca4c4d3966 --- /dev/null +++ b/hermes_cli/input_sanitize.py @@ -0,0 +1,70 @@ +"""Sanitize user prompt text leaked from terminal / paste control sequences.""" + +from __future__ import annotations + +import re + +_BRACKETED_PASTE_BOUNDARY_START = re.compile(r"(^|[\s\n>:\]\)])\[200~") +_BRACKETED_PASTE_BOUNDARY_END = re.compile(r"\[201~(?=$|[\s\n<\[\(\):;.,!?])") +_BRACKETED_PASTE_DEGRADED_START = re.compile(r"(^|[\s\n>:\]\)])00~") +_BRACKETED_PASTE_DEGRADED_END = re.compile(r"01~(?=$|[\s\n<\[\(\):;.,!?])") + +# Corruption signature from desktop bracketed-paste leaks (#62557). +_DESKTOP_PASTE_ARTIFACT = "~[[e" + + +def strip_leaked_bracketed_paste_wrappers(text: str) -> str: + """Strip leaked bracketed-paste wrapper markers from user-visible text. + + Defensive normalization for cases where terminal/prompt_toolkit parsing + fails and bracketed-paste markers end up in the buffer as literal text. + + Canonical wrappers are stripped unconditionally. Degraded visible forms like + ``[200~`` / ``[201~`` and ``00~`` / ``01~`` are removed only at boundaries + so embedded literals such as ``literal[200~tag`` stay intact. + """ + if not text: + return text + + text = ( + text.replace("\x1b[200~", "") + .replace("\x1b[201~", "") + .replace("^[[200~", "") + .replace("^[[201~", "") + ) + text = _BRACKETED_PASTE_BOUNDARY_START.sub(r"\1", text) + text = _BRACKETED_PASTE_BOUNDARY_END.sub("", text) + text = _BRACKETED_PASTE_DEGRADED_START.sub(r"\1", text) + text = _BRACKETED_PASTE_DEGRADED_END.sub("", text) + return text + + +def collapse_repeated_input_artifacts(text: str, min_repeats: int = 4) -> str: + """Drop a trailing run of the desktop ~[[e corruption signature (#62557).""" + if not text: + return text + + marker = _DESKTOP_PASTE_ARTIFACT + index = len(text) + repeat_count = 0 + while index >= len(marker) and text[index - len(marker) : index] == marker: + repeat_count += 1 + index -= len(marker) + + if repeat_count < min_repeats: + return text + + start = index + if start >= 2 and text[start - 2 : start] == "[e": + start -= 2 + elif start >= 1 and text[start - 1] == "[": + start -= 1 + return text[:start] + + +def sanitize_user_prompt_text(text: str) -> str: + """Normalize user-authored prompt text before persistence or model input.""" + if not isinstance(text, str) or not text: + return text + cleaned = strip_leaked_bracketed_paste_wrappers(text) + return collapse_repeated_input_artifacts(cleaned) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index febb4ec0d800..2f6e833934f6 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -8463,7 +8463,11 @@ def _(rid, params: dict) -> dict: @method("prompt.submit") def _(rid, params: dict) -> dict: - sid, text = params.get("session_id", ""), params.get("text", "") + from hermes_cli.input_sanitize import sanitize_user_prompt_text + + sid = params.get("session_id", "") + raw_text = params.get("text", "") + text = sanitize_user_prompt_text(raw_text) if isinstance(raw_text, str) else raw_text truncate_user_ordinal = params.get("truncate_before_user_ordinal") session, err = _sess_nowait(params, rid) if err: