Enforce Monaco paste cap before Monaco's capture handler (#35057)

Monaco's CopyPasteController claims paste on the editor container in the
capture phase before Orca's listener, so Ctrl/Cmd+V bypassed the 16 MiB
cap. Listen on the editor shell instead, scoped to this editor's subtree
and text focus, and only reject over-cap pastes; smaller pastes keep
Monaco's atomic native paste. Remove the DOM chunked-paste plumbing
(save deferral, isApplyingLargePasteRef) that could never run.

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
OrcaWin
2026-10-28 17:26:36 -07:00
committed by GitHub
co-authored by OrcaWin
parent d6e80dab06
commit a871bca8f7
11 changed files with 107 additions and 224 deletions
@@ -1,23 +0,0 @@
// Chunked pastes publish drafts only at completion; hold saves across every tab sharing the model URI to avoid writing outdated text.
const pastesInFlight = new Map<string, number>()
export function beginEditorLargePaste(modelUri: string): () => void {
pastesInFlight.set(modelUri, (pastesInFlight.get(modelUri) ?? 0) + 1)
let ended = false
return () => {
if (ended) {
return
}
ended = true
const remaining = (pastesInFlight.get(modelUri) ?? 1) - 1
if (remaining > 0) {
pastesInFlight.set(modelUri, remaining)
} else {
pastesInFlight.delete(modelUri)
}
}
}
export function isEditorLargePasteInFlight(modelUri: string): boolean {
return pastesInFlight.has(modelUri)
}
@@ -1,156 +0,0 @@
// @vitest-environment happy-dom
import { afterEach, expect, it, vi } from 'vitest'
import type { editor } from 'monaco-editor'
import { getDiskBaselineSignature } from '../../../../shared/disk-baseline-signature'
import { createEditorStore } from './editor-autosave-controller-test-fixture'
import { createEditorSaveQueue } from './editor-save-queue'
import { toEditorModelUri } from './editor-model-uri'
import { installMonacoEditorInputBindings } from './monaco-editor-input-bindings'
import { MONACO_PASTE_DIRECT_MAX_BYTES } from './monaco-large-text-paste'
import { compareDiskWritePrecondition } from '../../../../shared/file-write-disk-precondition'
import type { GuardedWriteArgs } from './editor-save-guarded-disk-fixture'
vi.mock('@/store', () => ({ useAppStore: { getState: () => ({}) } }))
vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback }))
vi.mock('@/lib/connection-context', () => ({ getConnectionIdForFile: () => undefined }))
vi.mock('./editor-shortcuts', () => ({
installEditorAddReviewNoteShortcut: () => () => undefined,
installEditorSaveShortcut: () => () => undefined,
installMonacoEditorFindShortcut: () => () => undefined
}))
const PATH = '/repo/file.ts'
afterEach(() => {
vi.unstubAllGlobals()
})
function createSingleLineEditor(initial: string) {
let value = initial
let selection = { startLineNumber: 1, startColumn: 1, endLineNumber: 1, endColumn: 1 }
const container = document.createElement('div')
document.body.appendChild(container)
const model = { uri: { toString: () => toEditorModelUri(PATH) }, getValue: () => value }
const editorStub = {
getModel: () => model,
getContainerDomNode: () => container,
hasTextFocus: () => true,
getValue: () => value,
getSelection: () => selection,
setSelection: (next: typeof selection) => {
selection = next
},
executeEdits: (_source: string, edits: { text: string }[]) => {
const at = selection.startColumn - 1
value = value.slice(0, at) + edits[0].text + value.slice(at)
return true
},
pushUndoStop: () => true,
addAction: () => ({ dispose: () => undefined })
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the bindings and chunked paste call only the methods stubbed above.
return { container, editorInstance: editorStub as unknown as editor.IStandaloneCodeEditor }
}
function pasteIntoGuardedTab(pasted: string) {
const files = new Map([[PATH, 'base']])
const writeFile = vi.fn(async (args: GuardedWriteArgs) => {
const conflict = args.diskPrecondition
? compareDiskWritePrecondition(
args.diskPrecondition,
files.get(args.filePath) ?? null,
args.content
)
: null
if (conflict === 'identical') {
return { ok: true, unchanged: true }
}
if (conflict) {
return { ok: false, diskConflict: conflict }
}
files.set(args.filePath, args.content)
return { ok: true }
})
vi.stubGlobal('api', { fs: { writeFile } })
const store = createEditorStore()
store.getState().openFile({
filePath: PATH,
relativePath: 'file.ts',
worktreeId: 'wt-1',
language: 'typescript',
mode: 'edit'
})
store.getState().setLastKnownDiskSignature(PATH, getDiskBaselineSignature('base'))
store.getState().setEditorDraft(PATH, 'base')
store.getState().markFileDirty(PATH, true)
const queue = createEditorSaveQueue(store)
const { container, editorInstance } = createSingleLineEditor('base')
const onContentChange = vi.fn((value: string) => store.getState().setEditorDraft(PATH, value))
installMonacoEditorInputBindings({
editorInstance,
worktreeId: undefined,
editorContainerRef: { current: container },
propsRef: {
current: { relativePath: 'file.ts', language: 'typescript', onSave: vi.fn(), onContentChange }
},
readOnlyRef: { current: false },
lastSyncedContentRef: { current: 'base' },
isApplyingLargePasteRef: { current: false },
commentPopoverRef: { current: null },
shouldShowMarkdownAnnotationsRef: { current: false },
setCommentPopover: vi.fn(),
setSelectionAnnotationTarget: vi.fn()
})
const paste = new Event('paste', { cancelable: true })
Object.defineProperty(paste, 'clipboardData', { value: { getData: () => pasted } })
container.dispatchEvent(paste)
return { files, writeFile, store, queue, onContentChange }
}
it('holds autosave while a chunked paste is in flight, then saves the pasted text guarded', async () => {
const pasted = 'x'.repeat(MONACO_PASTE_DIRECT_MAX_BYTES * 2)
const { files, writeFile, store, queue, onContentChange } = pasteIntoGuardedTab(pasted)
// Mid-paste the draft still holds the pre-paste text; autosave must not write it.
expect(await queue.queueSave(store.getState().openFiles[0], 'base', 'autosave')).toBe('failed')
expect(writeFile).not.toHaveBeenCalled()
await vi.waitFor(() => expect(onContentChange).toHaveBeenCalled())
expect(await queue.queueSave(store.getState().openFiles[0], 'base', 'autosave')).toBe('written')
expect(files.get(PATH)).toBe(`${pasted}base`)
expect(writeFile).toHaveBeenCalledWith(
expect.objectContaining({
diskPrecondition: {
expectedSignature: getDiskBaselineSignature('base'),
allowMissing: false,
requireLosslessUtf8: true
}
})
)
queue.dispose()
})
it('refuses a save after a 70 KiB paste when an agent wrote the file meanwhile', async () => {
const pasted = 'y'.repeat(70 * 1024)
const { files, writeFile, store, queue, onContentChange } = pasteIntoGuardedTab(pasted)
await vi.waitFor(() => expect(onContentChange).toHaveBeenCalled())
files.set(PATH, 'agent rewrite')
expect(await queue.queueSave(store.getState().openFiles[0], 'base', 'user')).toBe('blocked-stale')
expect(writeFile).toHaveBeenCalledWith(
expect.objectContaining({
content: `${pasted}base`,
diskPrecondition: {
expectedSignature: getDiskBaselineSignature('base'),
allowMissing: true,
requireLosslessUtf8: true
}
})
)
expect(files.get(PATH)).toBe('agent rewrite')
expect(store.getState().openFiles[0]?.externalMutation).toBe('changed')
queue.dispose()
})
@@ -24,7 +24,6 @@ import {
} from './editor-self-write-registry'
import { getDiskBaselineSignature } from '../../../../shared/disk-baseline-signature'
import { editorTabFileAccess } from '@/lib/local-file-access'
import { isEditorLargePasteInFlight } from './editor-large-paste-in-flight'
import {
markEditorSaveRefusal,
settleEditorRecordAfterWrite,
@@ -35,7 +34,6 @@ import {
import type { FileWriteResult } from '../../../../shared/file-write-disk-precondition'
import {
editorSavePrecondition,
editorTabModelUri,
editorSaveGrant,
writeEditorSave,
type EditorSaveResult,
@@ -122,9 +120,7 @@ export function createEditorSaveQueue(store: AppStoreApi): EditorSaveQueue {
// Explicit saves bypass autosave suspension but remain guarded; only Overwrite grants conflict consent.
if (
trigger === 'autosave' &&
(isAutosaveSuspendedForFile(liveFile) ||
isEditorLargePasteInFlight(editorTabModelUri(state, liveFile)) ||
holds.isHeld(state, liveFile))
(isAutosaveSuspendedForFile(liveFile) || holds.isHeld(state, liveFile))
) {
return 'failed'
}
@@ -0,0 +1,92 @@
// @vitest-environment happy-dom
import type { editor } from 'monaco-editor'
import { afterEach, expect, it, vi } from 'vitest'
import { toast } from 'sonner'
import { installMonacoEditorInputBindings } from './monaco-editor-input-bindings'
import { MONACO_PASTE_MAX_BYTES } from './monaco-large-text-paste'
vi.mock('sonner', () => ({ toast: { error: vi.fn() } }))
vi.mock('@/store', () => ({ useAppStore: { getState: vi.fn() } }))
vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback }))
vi.mock('./editor-shortcuts', () => ({
installEditorSaveShortcut: () => () => undefined,
installMonacoEditorFindShortcut: () => () => undefined,
installEditorAddReviewNoteShortcut: () => () => undefined
}))
afterEach(() => {
document.body.replaceChildren()
vi.clearAllMocks()
})
function mountEditor() {
const shell = document.createElement('div')
const container = document.createElement('div')
const editorInput = document.createElement('textarea')
const commentInput = document.createElement('textarea')
container.append(editorInput)
shell.append(container, commentInput)
document.body.append(shell)
let value = 'base'
// Mirrors Monaco's CopyPasteController, which claims paste on the container during construction.
container.addEventListener(
'paste',
(event) => {
event.preventDefault()
event.stopImmediatePropagation()
value += event.clipboardData?.getData('text/plain') ?? ''
},
{ capture: true }
)
const editorStub = {
getModel: () => ({ uri: { toString: () => 'file:///paste.txt' } }),
getValue: () => value,
getContainerDomNode: () => container,
hasTextFocus: () => true,
addAction: () => ({ dispose: () => undefined })
}
const onContentChange = vi.fn()
const binding = installMonacoEditorInputBindings({
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: an over-limit paste is rejected after only the methods stubbed above.
editorInstance: editorStub as unknown as editor.IStandaloneCodeEditor,
worktreeId: undefined,
editorContainerRef: { current: shell },
propsRef: {
current: {
relativePath: 'paste.txt',
language: 'plaintext',
onSave: vi.fn(),
onContentChange
}
},
readOnlyRef: { current: false },
commentPopoverRef: { current: null },
shouldShowMarkdownAnnotationsRef: { current: false },
setCommentPopover: vi.fn(),
setSelectionAnnotationTarget: vi.fn()
})
const paste = (target: HTMLElement, text: string): ClipboardEvent => {
const clipboardData = new DataTransfer()
clipboardData.setData('text/plain', text)
const event = new ClipboardEvent('paste', { bubbles: true, cancelable: true, clipboardData })
target.dispatchEvent(event)
return event
}
return { editorInput, commentInput, binding, paste, getValue: () => value, onContentChange }
}
it('rejects over-limit paste before Monaco container capture handler can claim it', async () => {
const editorMount = mountEditor()
editorMount.paste(editorMount.editorInput, 'x'.repeat(MONACO_PASTE_MAX_BYTES + 1))
await vi.waitFor(() => expect(toast.error).toHaveBeenCalledWith('Paste is too large.'))
expect(editorMount.getValue()).toBe('base')
expect(editorMount.onContentChange).not.toHaveBeenCalled()
editorMount.binding.disposeInputBindings()
})
it('leaves comment-overlay paste outside the editor route', () => {
const editorMount = mountEditor()
const event = editorMount.paste(editorMount.commentInput, 'x'.repeat(MONACO_PASTE_MAX_BYTES + 1))
expect(event.defaultPrevented).toBe(false)
editorMount.binding.disposeInputBindings()
})
@@ -14,8 +14,7 @@ import {
getMonacoMarkdownSelectionAnnotationTarget,
type MonacoMarkdownSelectionAnnotationTarget
} from './monaco-markdown-selection-annotation'
import { handleMonacoLargeTextPaste } from './monaco-large-text-paste'
import { beginEditorLargePaste } from './editor-large-paste-in-flight'
import { handleMonacoLargeTextPaste, MONACO_PASTE_MAX_BYTES } from './monaco-large-text-paste'
import type { MarkdownCommentPopoverState } from './use-monaco-markdown-annotations'
import type { MonacoEditorPropsRef } from './monaco-editor-mount-params'
@@ -25,8 +24,6 @@ type MonacoEditorInputBindingsParams = {
editorContainerRef: MutableRefObject<HTMLDivElement | null>
propsRef: MonacoEditorPropsRef
readOnlyRef: MutableRefObject<boolean>
lastSyncedContentRef: MutableRefObject<string>
isApplyingLargePasteRef: MutableRefObject<boolean>
commentPopoverRef: MutableRefObject<MarkdownCommentPopoverState | null>
shouldShowMarkdownAnnotationsRef: MutableRefObject<boolean>
setCommentPopover: Dispatch<SetStateAction<MarkdownCommentPopoverState | null>>
@@ -45,8 +42,6 @@ export function installMonacoEditorInputBindings(params: MonacoEditorInputBindin
editorContainerRef,
propsRef,
readOnlyRef,
lastSyncedContentRef,
isApplyingLargePasteRef,
commentPopoverRef,
shouldShowMarkdownAnnotationsRef,
setCommentPopover,
@@ -54,6 +49,7 @@ export function installMonacoEditorInputBindings(params: MonacoEditorInputBindin
} = params
const editorDomNode = editorInstance.getContainerDomNode()
const pasteCaptureNode = editorContainerRef.current ?? editorDomNode
const cleanupSaveShortcut = installEditorSaveShortcut(editorDomNode, () => {
const value = editorInstance.getValue()
propsRef.current.onSave(value)
@@ -103,22 +99,14 @@ export function installMonacoEditorInputBindings(params: MonacoEditorInputBindin
}
})
const onLargeTextPaste = (event: ClipboardEvent): void => {
let endSaveDeferral: (() => void) | null = null
if (!event.composedPath().includes(editorDomNode)) {
return
}
// Why: chunking re-serializes the document per chunk via @monaco-editor/react's onChange, so only enforce the cap.
handleMonacoLargeTextPaste(editorInstance, event, {
readOnly: readOnlyRef.current,
onPasteStart: () => {
isApplyingLargePasteRef.current = true
const modelUri = editorInstance.getModel()?.uri.toString()
endSaveDeferral = modelUri ? beginEditorLargePaste(modelUri) : null
},
directMaxBytes: MONACO_PASTE_MAX_BYTES,
onPasteResult: (result) => {
isApplyingLargePasteRef.current = false
endSaveDeferral?.()
if (result.status === 'pasted' || result.status === 'cancelled') {
const value = editorInstance.getValue()
lastSyncedContentRef.current = value
propsRef.current.onContentChange(value)
}
if (result.status === 'rejected' && result.reason === 'too-large') {
toast.error(
translate(
@@ -130,14 +118,14 @@ export function installMonacoEditorInputBindings(params: MonacoEditorInputBindin
}
})
}
editorDomNode.addEventListener('paste', onLargeTextPaste, { capture: true })
pasteCaptureNode.addEventListener('paste', onLargeTextPaste, { capture: true })
return {
disposeInputBindings: () => {
cleanupSaveShortcut()
cleanupFindShortcut()
cleanupAddReviewNoteShortcut()
editorDomNode.removeEventListener('paste', onLargeTextPaste, { capture: true })
pasteCaptureNode.removeEventListener('paste', onLargeTextPaste, { capture: true })
searchInFilesAction.dispose()
}
}
@@ -48,7 +48,6 @@ export type MonacoEditorMountParams = {
lastSyncedContentRef: MutableRefObject<string>
contentSyncModeRef: MutableRefObject<MonacoContentSyncMode>
isApplyingProgrammaticContentRef: MutableRefObject<boolean>
isApplyingLargePasteRef: MutableRefObject<boolean>
}
decorations: {
markdownDocLinkDecorationsRef: MutableRefObject<MarkdownDocLinkDecorationController | null>
@@ -212,7 +212,8 @@ export function handleMonacoLargeTextPaste(
if (options.readOnly) {
return { status: 'ignored', reason: 'read-only' }
}
if (!monacoEditor?.getModel()) {
// Why: find/replace/rename overlay inputs sit inside the editor container.
if (!monacoEditor?.getModel() || !monacoEditor.hasTextFocus()) {
return { status: 'ignored', reason: 'no-editor' }
}
@@ -13,7 +13,6 @@ export type MonacoContentSyncBridge = {
lastSyncedContentRef: MutableRefObject<string>
contentSyncModeRef: MutableRefObject<MonacoContentSyncMode>
isApplyingProgrammaticContentRef: MutableRefObject<boolean>
isApplyingLargePasteRef: MutableRefObject<boolean>
handleChange: (value: string | undefined) => void
}
@@ -35,7 +34,6 @@ export function useMonacoContentSyncBridge(params: {
// Suppress reconciliation’s real edit events so programmatic updates do not mark the file dirty.
const isApplyingProgrammaticContentRef = useRef(false)
const isApplyingLargePasteRef = useRef(false)
const handleChange = useCallback(
(value: string | undefined) => {
@@ -43,11 +41,6 @@ export function useMonacoContentSyncBridge(params: {
if (editorRef.current?.getModel()?.uri.toString() !== modelKey) {
return
}
// Publish chunked pastes only on completion instead of scheduling saves for intermediate content.
if (isApplyingLargePasteRef.current) {
lastSyncedContentRef.current = value
return
}
if (
shouldIgnoreMonacoContentChange({
modelKey,
@@ -90,7 +83,6 @@ export function useMonacoContentSyncBridge(params: {
lastSyncedContentRef,
contentSyncModeRef,
isApplyingProgrammaticContentRef,
isApplyingLargePasteRef,
handleChange
}
}
@@ -70,8 +70,7 @@ function mountParams(
contentRef: { current: '' },
lastSyncedContentRef: { current: '' },
contentSyncModeRef: { current: 'undoable' },
isApplyingProgrammaticContentRef: { current: false },
isApplyingLargePasteRef: { current: false }
isApplyingProgrammaticContentRef: { current: false }
},
decorations: {
markdownDocLinkDecorationsRef: { current: null },
@@ -77,8 +77,7 @@ function setupMount() {
contentRef: { current: 'A (stale cache)' },
lastSyncedContentRef: { current: 'A (stale cache)' },
contentSyncModeRef: { current: 'undoable' },
isApplyingProgrammaticContentRef: { current: false },
isApplyingLargePasteRef: { current: false }
isApplyingProgrammaticContentRef: { current: false }
},
decorations: {
markdownDocLinkDecorationsRef: { current: null },
@@ -56,8 +56,7 @@ export function useMonacoEditorMount(params: MonacoEditorMountParams): OnMount {
contentRef,
lastSyncedContentRef,
contentSyncModeRef,
isApplyingProgrammaticContentRef,
isApplyingLargePasteRef
isApplyingProgrammaticContentRef
},
decorations: {
markdownDocLinkDecorationsRef,
@@ -163,8 +162,6 @@ export function useMonacoEditorMount(params: MonacoEditorMountParams): OnMount {
editorContainerRef,
propsRef,
readOnlyRef,
lastSyncedContentRef,
isApplyingLargePasteRef,
commentPopoverRef,
shouldShowMarkdownAnnotationsRef,
setCommentPopover,
@@ -288,7 +285,6 @@ export function useMonacoEditorMount(params: MonacoEditorMountParams): OnMount {
lastSyncedContentRef,
contentSyncModeRef,
isApplyingProgrammaticContentRef,
isApplyingLargePasteRef,
markdownDocLinkDecorationsRef,
conflictDecorationsRef,
commentPopoverRef,