From b94c9775b1a9d394ee27580871e3a607ed4f138a Mon Sep 17 00:00:00 2001 From: luisfelipe-alt Date: Mon, 27 Jul 2026 10:18:58 -0700 Subject: [PATCH] fix(a2a-server): normalize CRLF line endings to LF in getProposedContent (#28531) --- packages/a2a-server/src/agent/task.test.ts | 103 +++++++++++++++++++++ packages/a2a-server/src/agent/task.ts | 15 ++- 2 files changed, 113 insertions(+), 5 deletions(-) diff --git a/packages/a2a-server/src/agent/task.test.ts b/packages/a2a-server/src/agent/task.test.ts index 5eb5098aeb..fd427c1030 100644 --- a/packages/a2a-server/src/agent/task.test.ts +++ b/packages/a2a-server/src/agent/task.test.ts @@ -752,4 +752,107 @@ describe('Task', () => { expect(changed3).toBe(true); }); }); + + describe('getProposedContent (CRLF Line Ending Normalization)', () => { + it('should successfully replace LF-based strings in CRLF-based files', async () => { + const fs = await import('node:fs'); + const path = await import('node:path'); + const os = await import('node:os'); + + const mockConfig = createMockConfig({ + getTargetDir: () => os.tmpdir(), + validatePathAccess: () => null, + }); + const mockEventBus: ExecutionEventBus = { + publish: vi.fn(), + on: vi.fn(), + off: vi.fn(), + once: vi.fn(), + removeAllListeners: vi.fn(), + finished: vi.fn(), + }; + + // @ts-expect-error - Calling private constructor + const task = new Task( + 'task-id', + 'context-id', + mockConfig as Config, + mockEventBus, + ); + + const tempFile = path.resolve(os.tmpdir(), 'crlf_test_file.txt'); + const crlfContent = 'line1\r\nline2\r\nline3\r\n'; + fs.writeFileSync(tempFile, crlfContent, 'utf8'); + + try { + const oldString = 'line2\n'; + const newString = 'line2-optimized\n'; + + const result = await task['getProposedContent']( + tempFile, + oldString, + newString, + ); + + expect(result).toContain('line2-optimized'); + expect(result).toContain('\r\n'); // It should preserve the original CRLF line endings + } finally { + if (fs.existsSync(tempFile)) { + fs.unlinkSync(tempFile); + } + } + }); + + it('should successfully replace CRLF-based strings in CRLF-based files by normalizing all to LF', async () => { + const fs = await import('node:fs'); + const path = await import('node:path'); + const os = await import('node:os'); + + const mockConfig = createMockConfig({ + getTargetDir: () => os.tmpdir(), + validatePathAccess: () => null, + }); + const mockEventBus: ExecutionEventBus = { + publish: vi.fn(), + on: vi.fn(), + off: vi.fn(), + once: vi.fn(), + removeAllListeners: vi.fn(), + finished: vi.fn(), + }; + + // @ts-expect-error - Calling private constructor + const task = new Task( + 'task-id', + 'context-id', + mockConfig as Config, + mockEventBus, + ); + + const tempFile = path.resolve( + os.tmpdir(), + 'crlf_test_file_crlf_inputs.txt', + ); + const crlfContent = 'line1\r\nline2\r\nline3\r\n'; + fs.writeFileSync(tempFile, crlfContent, 'utf8'); + + try { + const oldString = 'line2\r\n'; + const newString = 'line2-optimized\r\n'; + + const result = await task['getProposedContent']( + tempFile, + oldString, + newString, + ); + + expect(result).toContain('line2-optimized'); + expect(result).toContain('\r\n'); // It should preserve the original CRLF line endings + } finally { + if (fs.existsSync(tempFile)) { + fs.unlinkSync(tempFile); + } + } + }); + }); }); diff --git a/packages/a2a-server/src/agent/task.ts b/packages/a2a-server/src/agent/task.ts index 170f24e67c..ee4bfbb5bb 100644 --- a/packages/a2a-server/src/agent/task.ts +++ b/packages/a2a-server/src/agent/task.ts @@ -666,13 +666,18 @@ export class Task { } try { - const currentContent = await fs.readFile(resolvedPath, 'utf8'); - return this._applyReplacement( + const rawContent = await fs.readFile(resolvedPath, 'utf8'); + const hasCrlf = rawContent.includes('\r\n'); + const currentContent = rawContent.replace(/\r\n/g, '\n'); + const normalizedOldString = old_string.replace(/\r\n/g, '\n'); + const normalizedNewString = new_string.replace(/\r\n/g, '\n'); + const proposedContent = this._applyReplacement( currentContent, - old_string, - new_string, - old_string === '' && currentContent === '', + normalizedOldString, + normalizedNewString, + normalizedOldString === '' && currentContent === '', ); + return hasCrlf ? proposedContent.replace(/\n/g, '\r\n') : proposedContent; } catch (err) { if (!isNodeError(err) || err.code !== 'ENOENT') throw err; return '';