Skip to content

Commit b2764fb

Browse files
committed
fix(files): bound expanded edit output
1 parent fcc2369 commit b2764fb

6 files changed

Lines changed: 73 additions & 8 deletions

File tree

apps/sim/lib/copilot/tools/server/files/edit-content.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
EditContentError,
1818
type WorkspaceFileContentEdit,
1919
} from '@/lib/workspace-files/edit-content'
20+
import { MAX_WORKSPACE_FILE_CONTENT_BYTES } from '@/lib/workspace-files/orchestration'
2021
import {
2122
collectSimPageDiagnostics,
2223
HAND_WRITTEN_PAGE_MESSAGE,
@@ -205,7 +206,9 @@ export const editContentServerTool: BaseServerTool<EditContentArgs, EditContentR
205206
return { success: false, message: `Unknown patch strategy: "${intent.edit.strategy}"` }
206207
}
207208
try {
208-
finalContent = applyWorkspaceFileContentEdit(existing, edit)
209+
finalContent = applyWorkspaceFileContentEdit(existing, edit, {
210+
maxOutputBytes: MAX_WORKSPACE_FILE_CONTENT_BYTES,
211+
})
209212
} catch (error) {
210213
if (error instanceof EditContentError) {
211214
return {

apps/sim/lib/copilot/tools/server/files/file-preview.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ function extractPatchPreview(
5353
const search = typeof edit?.search === 'string' ? edit.search : ''
5454
if (!search) return undefined
5555
if (edit?.replaceAll === true) {
56-
return existingContent.split(search).join(streamedContent)
56+
return existingContent.replaceAll(search, streamedContent)
5757
}
5858
const firstIdx = existingContent.indexOf(search)
5959
if (firstIdx === -1) return undefined

apps/sim/lib/workspace-files/application/edit-workspace-file-content.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,4 +236,13 @@ describe('editWorkspaceFileContent', () => {
236236
).rejects.toThrow(/lines 1, 3/)
237237
expect(mockUpdateStoredContent).not.toHaveBeenCalled()
238238
})
239+
240+
it('rejects an oversized replaceAll result before writing it', async () => {
241+
mockFetchWorkspaceFileBuffer.mockResolvedValue(Buffer.from('a'.repeat(1_100_000), 'utf-8'))
242+
243+
await expect(
244+
edit({ mode: 'search_replace', search: 'a', content: 'x'.repeat(49), replaceAll: true })
245+
).rejects.toMatchObject({ code: 'payload_too_large' })
246+
expect(mockUpdateStoredContent).not.toHaveBeenCalled()
247+
})
239248
})

apps/sim/lib/workspace-files/application/edit-workspace-file-content.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -131,11 +131,17 @@ export const editWorkspaceFileContent = defineAuthorizedWorkspaceFileUseCase({
131131
const before = buffer.toString('utf-8')
132132
let after: string
133133
try {
134-
after = applyWorkspaceFileContentEdit(before, input.edit)
134+
after = applyWorkspaceFileContentEdit(before, input.edit, {
135+
maxOutputBytes: MAX_WORKSPACE_FILE_CONTENT_BYTES,
136+
})
135137
} catch (error) {
136138
if (error instanceof EditContentError) {
137139
throw new OrchestrationError(
138-
error.failure.reason === 'not_found' ? 'not_found' : 'validation',
140+
error.failure.reason === 'not_found'
141+
? 'not_found'
142+
: error.failure.reason === 'output_too_large'
143+
? 'payload_too_large'
144+
: 'validation',
139145
error.message
140146
)
141147
}

apps/sim/lib/workspace-files/edit-content.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,12 @@ describe('applyStringReplacement', () => {
6969
it('replaces every non-overlapping match when explicitly requested', () => {
7070
expect(applyStringReplacement('a a a', 'a', 'b', true)).toBe('b b b')
7171
})
72+
73+
it('rejects an oversized replaceAll result before constructing it', () => {
74+
expect(() =>
75+
applyStringReplacement('aaaa', 'a', '0123456789', true, { maxOutputBytes: 20 })
76+
).toThrow(/exceeds the 20 byte limit/)
77+
})
7278
})
7379

7480
describe('applyWorkspaceFileContentEdit', () => {

apps/sim/lib/workspace-files/edit-content.ts

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
1+
import { Buffer } from 'node:buffer'
2+
13
export type EditContentFailure =
24
| { reason: 'empty_search' }
35
| { reason: 'not_found' }
46
| { reason: 'ambiguous'; lineNumbers: number[] }
57
| { reason: 'invalid_occurrence' }
68
| { reason: 'invalid_anchor_order' }
9+
| { reason: 'output_too_large'; maxBytes: number }
10+
11+
export interface EditContentOptions {
12+
maxOutputBytes?: number
13+
}
714

815
export class EditContentError extends Error {
916
constructor(
@@ -61,6 +68,30 @@ function scanMatches(text: string, search: string): MatchScan {
6168
return { count, lineNumbers }
6269
}
6370

71+
function assertReplacementOutputWithinLimit(
72+
text: string,
73+
search: string,
74+
content: string,
75+
count: number,
76+
maxOutputBytes: number | undefined
77+
): void {
78+
if (maxOutputBytes === undefined) return
79+
80+
const retainedBytes = Buffer.byteLength(text) - count * Buffer.byteLength(search)
81+
const replacementBytes = Buffer.byteLength(content)
82+
const availableReplacementBytes = maxOutputBytes - retainedBytes
83+
const exceedsLimit =
84+
availableReplacementBytes < 0 ||
85+
(replacementBytes > 0 && count > Math.floor(availableReplacementBytes / replacementBytes))
86+
87+
if (exceedsLimit) {
88+
throw new EditContentError(`Edit result exceeds the ${maxOutputBytes} byte limit`, {
89+
reason: 'output_too_large',
90+
maxBytes: maxOutputBytes,
91+
})
92+
}
93+
}
94+
6495
/**
6596
* Replaces the single occurrence of `search`, or refuses.
6697
*
@@ -72,7 +103,8 @@ export function applyStringReplacement(
72103
text: string,
73104
search: string,
74105
content: string,
75-
replaceAll = false
106+
replaceAll = false,
107+
options?: EditContentOptions
76108
): string {
77109
if (search.length === 0) {
78110
throw new EditContentError('Search text cannot be empty', { reason: 'empty_search' })
@@ -94,7 +126,15 @@ export function applyStringReplacement(
94126
)
95127
}
96128

97-
if (replaceAll) return text.split(search).join(content)
129+
assertReplacementOutputWithinLimit(
130+
text,
131+
search,
132+
content,
133+
replaceAll ? count : 1,
134+
options?.maxOutputBytes
135+
)
136+
137+
if (replaceAll) return text.replaceAll(search, content)
98138

99139
const index = text.indexOf(search)
100140
return text.slice(0, index) + content + text.slice(index + search.length)
@@ -188,10 +228,11 @@ function contentLines(content: string): string[] {
188228
*/
189229
export function applyWorkspaceFileContentEdit(
190230
text: string,
191-
edit: WorkspaceFileContentEdit
231+
edit: WorkspaceFileContentEdit,
232+
options?: EditContentOptions
192233
): string {
193234
if (edit.mode === 'search_replace') {
194-
return applyStringReplacement(text, edit.search, edit.content, edit.replaceAll)
235+
return applyStringReplacement(text, edit.search, edit.content, edit.replaceAll, options)
195236
}
196237

197238
const lines = splitLines(text)

0 commit comments

Comments
 (0)