basant307/AI_Governance_Project
048
1/**2 * @license3 * Copyright 2025 Google LLC4 * SPDX-License-Identifier: Apache-2.05 */6 7import {8 describe,9 it,10 expect,11 beforeEach,12 afterEach,13 vi,14 type Mocked,15} from 'vitest';16import type { WriteFileToolParams } from './write-file.js';17import { WriteFileTool } from './write-file.js';18import { ToolErrorType } from './tool-error.js';19import type { FileDiff, ToolEditConfirmationDetails } from './tools.js';20import { ToolConfirmationOutcome } from './tools.js';21import type { Config } from '../config/config.js';22import { ApprovalMode } from '../config/config.js';23import type { ToolRegistry } from './tool-registry.js';24import { clearAutoMemoryRootCache } from '../memory/paths.js';25import path from 'node:path';26import fs from 'node:fs';27import os from 'node:os';28import { GeminiClient } from '../core/client.js';29import { createMockWorkspaceContext } from '../test-utils/mockWorkspaceContext.js';30import { FileReadCache } from '../services/fileReadCache.js';31import { StandardFileSystemService } from '../services/fileSystemService.js';32import { CommitAttributionService } from '../services/commitAttribution.js';33 34const rootDir = path.resolve(os.tmpdir(), 'qwen-code-test-root');35 36// --- MOCKS ---37vi.mock('../core/client.js');38 39let mockGeminiClientInstance: Mocked<GeminiClient>;40 41// Mock Config42const fsService = new StandardFileSystemService();43const fileReadCache = new FileReadCache();44const mockFileHistoryService = { trackEdit: vi.fn() };45const mockConfigInternal = {46 getTargetDir: () => rootDir,47 getProjectRoot: () => rootDir,48 getApprovalMode: vi.fn(() => ApprovalMode.DEFAULT),49 setApprovalMode: vi.fn(),50 getGeminiClient: vi.fn(), // Initialize as a plain mock function51 getBaseLlmClient: vi.fn(), // Initialize as a plain mock function52 getFileSystemService: () => fsService,53 getWorkspaceContext: () => createMockWorkspaceContext(rootDir),54 getApiKey: () => 'test-key',55 getModel: () => 'test-model',56 getSandbox: () => false,57 getDebugMode: () => false,58 getQuestion: () => undefined,59 getFullContext: () => false,60 getToolDiscoveryCommand: () => undefined,61 getToolCallCommand: () => undefined,62 getMcpServerCommand: () => undefined,63 getMcpServers: () => undefined,64 getUserAgent: () => 'test-agent',65 getUserMemory: () => '',66 setUserMemory: vi.fn(),67 getGeminiMdFileCount: () => 0,68 setGeminiMdFileCount: vi.fn(),69 getToolRegistry: () =>70 ({71 registerTool: vi.fn(),72 discoverTools: vi.fn(),73 }) as unknown as ToolRegistry,74 getDefaultFileEncoding: () => 'utf-8',75 getFileReadCache: () => fileReadCache,76 getFileReadCacheDisabled: () => false,77 getFileHistoryService: () => mockFileHistoryService,78};79const mockConfig = mockConfigInternal as unknown as Config;80 81vi.mock('../telemetry/loggers.js', () => ({82 logFileOperation: vi.fn(),83}));84 85// --- END MOCKS ---86 87describe('WriteFileTool', () => {88 let tool: WriteFileTool;89 let tempDir: string;90 91 beforeEach(() => {92 vi.clearAllMocks();93 // The fileReadCache is module-scope (declared at L41) and shared94 // across every test in this file, so state from one test leaks95 // into the next. Clear it before each test so every test starts96 // from a known-empty cache. CI surfaced this on Linux only because97 // file-creation order across tests differs by platform.98 fileReadCache.clear();99 // Create a unique temporary directory for files created outside the root100 tempDir = fs.mkdtempSync(101 path.join(os.tmpdir(), 'write-file-test-external-'),102 );103 // Ensure the rootDir for the tool exists104 if (!fs.existsSync(rootDir)) {105 fs.mkdirSync(rootDir, { recursive: true });106 }107 108 // Setup GeminiClient mock109 mockGeminiClientInstance = new (vi.mocked(GeminiClient))(110 mockConfig,111 ) as Mocked<GeminiClient>;112 vi.mocked(GeminiClient).mockImplementation(() => mockGeminiClientInstance);113 114 // Now that mockGeminiClientInstance is initialized, set the mock implementation for getGeminiClient115 mockConfigInternal.getGeminiClient.mockReturnValue(116 mockGeminiClientInstance,117 );118 119 tool = new WriteFileTool(mockConfig);120 121 // Reset mocks before each test122 mockConfigInternal.getApprovalMode.mockReturnValue(ApprovalMode.DEFAULT);123 mockConfigInternal.setApprovalMode.mockClear();124 });125 126 afterEach(() => {127 // Clean up the temporary directories128 if (fs.existsSync(tempDir)) {129 fs.rmSync(tempDir, { recursive: true, force: true });130 }131 if (fs.existsSync(rootDir)) {132 fs.rmSync(rootDir, { recursive: true, force: true });133 }134 vi.clearAllMocks();135 });136 137 /**138 * Simulate the model having read `filePath` earlier in the session,139 * so the WriteFileTool's prior-read enforcement does not reject the140 * subsequent overwrite. New-file creation paths do not need this.141 */142 function seedPriorRead(filePath: string) {143 const stats = fs.statSync(filePath);144 fileReadCache.recordRead(filePath, stats, {145 full: true,146 cacheable: true,147 });148 }149 150 describe('build', () => {151 it('should return an invocation for a valid absolute path within root', () => {152 const params = {153 file_path: path.join(rootDir, 'test.txt'),154 content: 'hello',155 };156 const invocation = tool.build(params);157 expect(invocation).toBeDefined();158 expect(invocation.params).toEqual(params);159 });160 161 it('should throw an error for a relative path', () => {162 const params = { file_path: 'test.txt', content: 'hello' };163 expect(() => tool.build(params)).toThrow(/File path must be absolute/);164 });165 166 it('should allow a path outside root (external path support)', () => {167 const outsidePath = path.resolve(tempDir, 'outside-root.txt');168 const params = {169 file_path: outsidePath,170 content: 'hello',171 };172 const invocation = tool.build(params);173 expect(invocation).toBeDefined();174 });175 176 it('should throw an error if path is a directory', () => {177 const dirAsFilePath = path.join(rootDir, 'a_directory');178 fs.mkdirSync(dirAsFilePath);179 const params = {180 file_path: dirAsFilePath,181 content: 'hello',182 };183 expect(() => tool.build(params)).toThrow(184 `Path is a directory, not a file: ${dirAsFilePath}`,185 );186 });187 188 it('should coerce null content into an empty string', () => {189 const params = {190 file_path: path.join(rootDir, 'test.txt'),191 content: null,192 } as unknown as WriteFileToolParams; // Intentionally non-conforming193 expect(() => tool.build(params)).toBeDefined();194 });195 196 it('should throw error if the file_path is empty', () => {197 const dirAsFilePath = path.join(rootDir, 'a_directory');198 fs.mkdirSync(dirAsFilePath);199 const params = {200 file_path: '',201 content: '',202 };203 expect(() => tool.build(params)).toThrow(`Missing or empty "file_path"`);204 });205 206 it.skipIf(process.platform === 'win32')(207 'should unescape shell-escaped spaces in file_path',208 () => {209 // On Windows, unescapePath is a no-op and backslashes are path210 // separators, so the expected unescape behavior doesn't apply.211 const escapedPath = path.join(rootDir, 'my\\ file.txt');212 const params = {213 file_path: escapedPath,214 content: 'hello',215 };216 const invocation = tool.build(params);217 expect(invocation).toBeDefined();218 expect(invocation.params.file_path).toBe(219 path.join(rootDir, 'my file.txt'),220 );221 },222 );223 });224 225 describe('shouldConfirmExecute', () => {226 const abortSignal = new AbortController().signal;227 228 it('should always return ask from getDefaultPermission', async () => {229 const filePath = path.join(rootDir, 'confirm_permission_file.txt');230 const params = { file_path: filePath, content: 'test content' };231 const invocation = tool.build(params);232 const permission = await invocation.getDefaultPermission();233 expect(permission).toBe('ask');234 });235 236 it('auto-allows private memory writes but proposes team memory writes', async () => {237 const prev = process.env['QWEN_CODE_MEMORY_LOCAL'];238 process.env['QWEN_CODE_MEMORY_LOCAL'] = '1';239 clearAutoMemoryRootCache();240 try {241 const privatePath = path.join(242 rootDir,243 '.qwen',244 'memory',245 'user',246 'x.md',247 );248 const teamPath = path.join(249 rootDir,250 '.qwen',251 'team-memory',252 'feedback',253 'x.md',254 );255 expect(256 await tool257 .build({ file_path: privatePath, content: 'c' })258 .getDefaultPermission(),259 ).toBe('allow');260 expect(261 await tool262 .build({ file_path: teamPath, content: 'c' })263 .getDefaultPermission(),264 ).toBe('ask');265 } finally {266 if (prev === undefined) {267 delete process.env['QWEN_CODE_MEMORY_LOCAL'];268 } else {269 process.env['QWEN_CODE_MEMORY_LOCAL'] = prev;270 }271 clearAutoMemoryRootCache();272 }273 });274 275 it('blocks writing a secret to a team-memory path', () => {276 const params = {277 file_path: path.join(rootDir, '.qwen', 'team-memory', 'feedback.md'),278 content: `token = ghp_${'a'.repeat(36)}`,279 };280 expect(() => tool.build(params)).toThrow(281 /shared with all repository collaborators/i,282 );283 });284 285 it('blocks a secret added to team-memory content before execute', async () => {286 const filePath = path.join(287 rootDir,288 '.qwen',289 'team-memory',290 'feedback.md',291 );292 const invocation = tool.build({293 file_path: filePath,294 content: 'clean content',295 });296 invocation.params.content = `token = ghp_${'a'.repeat(36)}`;297 298 const result = await invocation.execute(abortSignal);299 300 expect(JSON.stringify(result)).toMatch(301 /shared with all repository collaborators/i,302 );303 // The blocked write must carry an `error` field so the framework304 // treats it as a failure, not a silent success.305 expect(result.error?.type).toBe(ToolErrorType.INVALID_TOOL_PARAMS);306 expect(result.error?.message).toMatch(307 /shared with all repository collaborators/i,308 );309 expect(fs.existsSync(filePath)).toBe(false);310 });311 312 it('should throw if _getCorrectedFileContent returns an error', async () => {313 const filePath = path.join(rootDir, 'confirm_error_file.txt');314 const params = { file_path: filePath, content: 'test content' };315 fs.writeFileSync(filePath, 'original', { mode: 0o000 });316 seedPriorRead(filePath);317 318 const readError = new Error('Simulated read error for confirmation');319 vi.spyOn(fsService, 'readTextFile').mockImplementationOnce(() =>320 Promise.reject(readError),321 );322 323 const invocation = tool.build(params);324 await expect(325 invocation.getConfirmationDetails(abortSignal),326 ).rejects.toThrow('Error reading existing file for confirmation');327 328 fs.chmodSync(filePath, 0o600);329 });330 331 it('should request confirmation with diff for a new file', async () => {332 const filePath = path.join(rootDir, 'confirm_new_file.txt');333 const proposedContent = 'Proposed new content for confirmation.';334 335 const params = { file_path: filePath, content: proposedContent };336 const invocation = tool.build(params);337 const confirmation = (await invocation.getConfirmationDetails(338 abortSignal,339 )) as ToolEditConfirmationDetails;340 341 expect(confirmation).toEqual(342 expect.objectContaining({343 title: `Confirm Write: ${path.basename(filePath)}`,344 fileName: 'confirm_new_file.txt',345 fileDiff: expect.stringContaining(proposedContent),346 }),347 );348 expect(confirmation.fileDiff).toMatch(349 /--- confirm_new_file.txt\tCurrent/,350 );351 expect(confirmation.fileDiff).toMatch(352 /\+\+\+ confirm_new_file.txt\tProposed/,353 );354 });355 356 it('should request confirmation with diff for an existing file', async () => {357 const filePath = path.join(rootDir, 'confirm_existing_file.txt');358 const originalContent = 'Original content for confirmation.';359 const proposedContent = 'Proposed replacement for confirmation.';360 fs.writeFileSync(filePath, originalContent, 'utf8');361 seedPriorRead(filePath);362 363 const params = { file_path: filePath, content: proposedContent };364 const invocation = tool.build(params);365 const confirmation = (await invocation.getConfirmationDetails(366 abortSignal,367 )) as ToolEditConfirmationDetails;368 369 expect(confirmation).toEqual(370 expect.objectContaining({371 title: `Confirm Write: ${path.basename(filePath)}`,372 fileName: 'confirm_existing_file.txt',373 fileDiff: expect.stringContaining(proposedContent),374 }),375 );376 expect(confirmation.fileDiff).toMatch(377 originalContent.replace(/[.*+?^${}()|[\\]\\]/g, '\\$&'),378 );379 });380 });381 382 describe('execute', () => {383 const abortSignal = new AbortController().signal;384 385 it('should return error if _getCorrectedFileContent returns an error during execute', async () => {386 const filePath = path.join(rootDir, 'execute_error_file.txt');387 const params = { file_path: filePath, content: 'test content' };388 fs.writeFileSync(filePath, 'original', { mode: 0o000 });389 seedPriorRead(filePath);390 391 vi.spyOn(fsService, 'readTextFile').mockImplementationOnce(() => {392 const readError = new Error('Simulated read error for execute');393 return Promise.reject(readError);394 });395 396 const invocation = tool.build(params);397 const result = await invocation.execute(abortSignal);398 expect(result.llmContent).toContain('Error checking existing file');399 expect(result.returnDisplay).toMatch(400 /Error checking existing file: Simulated read error for execute/,401 );402 expect(result.error).toEqual({403 message:404 'Error checking existing file: Simulated read error for execute',405 type: ToolErrorType.FILE_WRITE_FAILURE,406 });407 408 fs.chmodSync(filePath, 0o600);409 });410 411 it('should write a new file and return diff', async () => {412 const filePath = path.join(rootDir, 'execute_new_file.txt');413 const proposedContent = 'Proposed new content for execute.';414 415 const params = { file_path: filePath, content: proposedContent };416 const invocation = tool.build(params);417 418 const confirmDetails =419 await invocation.getConfirmationDetails(abortSignal);420 if (421 typeof confirmDetails === 'object' &&422 'onConfirm' in confirmDetails &&423 confirmDetails.onConfirm424 ) {425 await confirmDetails.onConfirm(ToolConfirmationOutcome.ProceedOnce);426 }427 428 const result = await invocation.execute(abortSignal);429 430 expect(result.llmContent).toMatch(431 /Successfully created and wrote to new file/,432 );433 expect(mockFileHistoryService.trackEdit).toHaveBeenCalledWith(filePath);434 expect(fs.existsSync(filePath)).toBe(true);435 const { content: writtenContent } = await fsService.readTextFile({436 path: filePath,437 });438 expect(writtenContent).toBe(proposedContent);439 const display = result.returnDisplay as FileDiff;440 expect(display.fileName).toBe('execute_new_file.txt');441 expect(display.fileDiff).toMatch(/--- execute_new_file.txt\tOriginal/);442 expect(display.fileDiff).toMatch(/\+\+\+ execute_new_file.txt\tWritten/);443 expect(display.fileDiff).toMatch(444 proposedContent.replace(/[.*+?^${}()|[\\]\\]/g, '\\$&'),445 );446 });447 448 // trackEdit is best-effort: a FileHistoryService failure (disk full,449 // permissions, corrupted state) must never break the write_file tool.450 it('completes the write even when trackEdit throws', async () => {451 const filePath = path.join(rootDir, 'write_when_trackedit_fails.txt');452 const proposedContent = 'Content that survives trackEdit failure.';453 mockFileHistoryService.trackEdit.mockRejectedValueOnce(454 new Error('disk full'),455 );456 457 const params = { file_path: filePath, content: proposedContent };458 const invocation = tool.build(params);459 460 const confirmDetails =461 await invocation.getConfirmationDetails(abortSignal);462 if (463 typeof confirmDetails === 'object' &&464 'onConfirm' in confirmDetails &&465 confirmDetails.onConfirm466 ) {467 await confirmDetails.onConfirm(ToolConfirmationOutcome.ProceedOnce);468 }469 470 const result = await invocation.execute(abortSignal);471 472 expect(mockFileHistoryService.trackEdit).toHaveBeenCalledWith(filePath);473 expect(result.llmContent).toMatch(474 /Successfully created and wrote to new file/,475 );476 expect(fs.existsSync(filePath)).toBe(true);477 const { content: writtenContent } = await fsService.readTextFile({478 path: filePath,479 });480 expect(writtenContent).toBe(proposedContent);481 });482 483 // Pin the upstream-aligned ordering: trackEdit MUST run before the484 // pre-write checkPriorRead. The upstream `claude-code/src/tools/485 // FileEditTool` comment on the equivalent block says:486 //487 // "These awaits must stay OUTSIDE the critical section below — a488 // yield between the staleness check and writeTextContent lets489 // concurrent edits interleave."490 //491 // Without this ordering the multi-hundred-ms `trackEdit` sat492 // between checkPriorRead and writeTextFile, widening the493 // already-acknowledged stat-then-write race window.494 //495 // Test strategy: install a `trackEdit` mock that mutates the file496 // on disk before returning. That mutation must be detected by the497 // pre-write `checkPriorRead`. That only happens if `trackEdit`498 // runs BEFORE the pre-write check — the broken ordering would run499 // the pre-write check first (passing on pre-mutation stats), then500 // trackEdit (which mutates), then write (which clobbers the501 // external mutation silently).502 //503 // Asserting on `result.error` directly tests the behavioral504 // invariant rather than the call-ordering proxy, so it survives505 // future refactors that preserve the invariant even if they shift506 // the number of `cache.check` calls.507 it('backs up before the pre-write freshness check (TOCTOU ordering)', async () => {508 const filePath = path.join(rootDir, 'toctou_ordering.txt');509 const initialContent = 'pre-existing content';510 fs.writeFileSync(filePath, initialContent, 'utf8');511 const stats = fs.statSync(filePath);512 fileReadCache.recordRead(filePath, stats, {513 full: true,514 cacheable: true,515 });516 517 mockFileHistoryService.trackEdit.mockImplementation(async () => {518 // Simulate an external write that lands while trackEdit is519 // copying the file. Bumping mtime by 5 s makes the change520 // reliably "newer" under the cache's ~1 s comparison521 // granularity on macOS.522 const newTime = new Date(Date.now() + 5000);523 fs.utimesSync(filePath, newTime, newTime);524 });525 526 const params = { file_path: filePath, content: 'new content' };527 const invocation = tool.build(params);528 529 const confirmDetails =530 await invocation.getConfirmationDetails(abortSignal);531 if (532 typeof confirmDetails === 'object' &&533 'onConfirm' in confirmDetails &&534 confirmDetails.onConfirm535 ) {536 await confirmDetails.onConfirm(ToolConfirmationOutcome.ProceedOnce);537 }538 const result = await invocation.execute(abortSignal);539 540 // trackEdit must have actually fired.541 expect(mockFileHistoryService.trackEdit).toHaveBeenCalledWith(filePath);542 // The pre-write check must have caught the in-trackEdit mutation543 // and rejected, proving trackEdit ran BEFORE the pre-write check.544 expect(result.error?.type).toBe(ToolErrorType.FILE_CHANGED_SINCE_READ);545 // The file on disk is unchanged (rejected, not overwritten).546 expect(fs.readFileSync(filePath, 'utf8')).toBe(initialContent);547 });548 549 it('should overwrite an existing file and return diff', async () => {550 const filePath = path.join(rootDir, 'execute_existing_file.txt');551 const initialContent = 'Initial content for execute.';552 const proposedContent = 'Proposed overwrite for execute.';553 fs.writeFileSync(filePath, initialContent, 'utf8');554 seedPriorRead(filePath);555 556 const params = { file_path: filePath, content: proposedContent };557 const invocation = tool.build(params);558 559 const confirmDetails =560 await invocation.getConfirmationDetails(abortSignal);561 if (562 typeof confirmDetails === 'object' &&563 'onConfirm' in confirmDetails &&564 confirmDetails.onConfirm565 ) {566 await confirmDetails.onConfirm(ToolConfirmationOutcome.ProceedOnce);567 }568 569 const result = await invocation.execute(abortSignal);570 571 expect(result.llmContent).toMatch(/Successfully overwrote file/);572 const { content: writtenContent } = await fsService.readTextFile({573 path: filePath,574 });575 expect(writtenContent).toBe(proposedContent);576 const display = result.returnDisplay as FileDiff;577 expect(display.fileName).toBe('execute_existing_file.txt');578 expect(display.fileDiff).toMatch(579 initialContent.replace(/[.*+?^${}()|[\\]\\]/g, '\\$&'),580 );581 expect(display.fileDiff).toMatch(582 proposedContent.replace(/[.*+?^${}()|[\\]\\]/g, '\\$&'),583 );584 });585 586 it('should treat metadata ENOENT as new file when readTextFile returned empty content', async () => {587 const filePath = path.join(rootDir, 'execute_acp_like_missing_file.txt');588 const proposedContent = 'content from acp-like flow';589 const writeSpy = vi.spyOn(fsService, 'writeTextFile');590 591 // Simulate ENOENT: file does not exist, readTextFile throws ENOENT.592 const enoentError = new Error('File not found') as NodeJS.ErrnoException;593 enoentError.code = 'ENOENT';594 vi.spyOn(fsService, 'readTextFile').mockRejectedValueOnce(enoentError);595 596 const params = { file_path: filePath, content: proposedContent };597 const invocation = tool.build(params);598 const result = await invocation.execute(abortSignal);599 600 expect(result.error).toBeUndefined();601 expect(result.llmContent).toMatch(602 /Successfully created and wrote to new file/,603 );604 expect(writeSpy).toHaveBeenCalledWith({605 path: filePath,606 content: proposedContent,607 _meta: {608 bom: false,609 encoding: undefined,610 },611 });612 expect(fs.existsSync(filePath)).toBe(true);613 expect(fs.readFileSync(filePath, 'utf8')).toBe(proposedContent);614 });615 616 it('should create directory if it does not exist', async () => {617 const dirPath = path.join(rootDir, 'new_dir_for_write');618 const filePath = path.join(dirPath, 'file_in_new_dir.txt');619 const content = 'Content in new directory';620 621 const params = { file_path: filePath, content };622 const invocation = tool.build(params);623 // Simulate confirmation if your logic requires it before execute, or remove if not needed for this path624 const confirmDetails =625 await invocation.getConfirmationDetails(abortSignal);626 if (627 typeof confirmDetails === 'object' &&628 'onConfirm' in confirmDetails &&629 confirmDetails.onConfirm630 ) {631 await confirmDetails.onConfirm(ToolConfirmationOutcome.ProceedOnce);632 }633 634 await invocation.execute(abortSignal);635 636 expect(fs.existsSync(dirPath)).toBe(true);637 expect(fs.statSync(dirPath).isDirectory()).toBe(true);638 expect(fs.existsSync(filePath)).toBe(true);639 expect(fs.readFileSync(filePath, 'utf8')).toBe(content);640 });641 642 it('should include modification message when proposed content is modified', async () => {643 const filePath = path.join(rootDir, 'new_file_modified.txt');644 const content = 'New file content modified by user';645 646 const params = {647 file_path: filePath,648 content,649 modified_by_user: true,650 };651 const invocation = tool.build(params);652 const result = await invocation.execute(abortSignal);653 654 expect(result.llmContent).toMatch(/User modified the `content`/);655 });656 657 it('should not include modification message when proposed content is not modified', async () => {658 const filePath = path.join(rootDir, 'new_file_unmodified.txt');659 const content = 'New file content not modified';660 661 const params = {662 file_path: filePath,663 content,664 modified_by_user: false,665 };666 const invocation = tool.build(params);667 const result = await invocation.execute(abortSignal);668 669 expect(result.llmContent).not.toMatch(/User modified the `content`/);670 });671 672 it('should not include modification message when modified_by_user is not provided', async () => {673 const filePath = path.join(rootDir, 'new_file_unmodified.txt');674 const content = 'New file content not modified';675 676 const params = {677 file_path: filePath,678 content,679 };680 const invocation = tool.build(params);681 const result = await invocation.execute(abortSignal);682 683 expect(result.llmContent).not.toMatch(/User modified the `content`/);684 });685 686 it.skipIf(process.platform === 'win32')(687 'should write to a file with spaces in its name when given an escaped path',688 async () => {689 // On Windows, unescapePath is a no-op and backslashes are path690 // separators, so shell-escaping behavior doesn't apply.691 const realPath = path.join(rootDir, 'my spaced write.txt');692 const escapedPath = path.join(rootDir, 'my\\ spaced\\ write.txt');693 const content = 'Written via escaped path.';694 695 const params = { file_path: escapedPath, content };696 const invocation = tool.build(params);697 698 const confirmDetails =699 await invocation.getConfirmationDetails(abortSignal);700 if (701 typeof confirmDetails === 'object' &&702 'onConfirm' in confirmDetails &&703 confirmDetails.onConfirm704 ) {705 await confirmDetails.onConfirm(ToolConfirmationOutcome.ProceedOnce);706 }707 708 const result = await invocation.execute(abortSignal);709 710 // Should succeed — file created at the unescaped (real) path711 expect(result.llmContent).toMatch(/Successfully created and wrote/);712 expect(fs.existsSync(realPath)).toBe(true);713 expect(fs.readFileSync(realPath, 'utf8')).toBe(content);714 },715 );716 });717 718 describe('workspace boundary validation', () => {719 it('should validate paths are within workspace root', () => {720 const params = {721 file_path: path.join(rootDir, 'file.txt'),722 content: 'test content',723 };724 expect(() => tool.build(params)).not.toThrow();725 });726 727 it('should allow paths outside workspace root (external path support)', () => {728 const params = {729 file_path: '/etc/passwd',730 content: 'test',731 };732 const invocation = tool.build(params);733 expect(invocation).toBeDefined();734 });735 });736 737 describe('specific error types for write failures', () => {738 const abortSignal = new AbortController().signal;739 740 it('should return PERMISSION_DENIED error when write fails with EACCES', async () => {741 const filePath = path.join(rootDir, 'permission_denied_file.txt');742 const content = 'test content';743 744 // Mock FileSystemService writeTextFile to throw EACCES error745 vi.spyOn(fsService, 'writeTextFile').mockImplementationOnce(() => {746 const error = new Error('Permission denied') as NodeJS.ErrnoException;747 error.code = 'EACCES';748 return Promise.reject(error);749 });750 751 const params = { file_path: filePath, content };752 const invocation = tool.build(params);753 const result = await invocation.execute(abortSignal);754 755 expect(result.error?.type).toBe(ToolErrorType.PERMISSION_DENIED);756 expect(result.llmContent).toContain(757 `Permission denied writing to file: ${filePath} (EACCES)`,758 );759 expect(result.returnDisplay).toContain(760 `Permission denied writing to file: ${filePath} (EACCES)`,761 );762 });763 764 it('should return NO_SPACE_LEFT error when write fails with ENOSPC', async () => {765 const filePath = path.join(rootDir, 'no_space_file.txt');766 const content = 'test content';767 768 // Mock FileSystemService writeTextFile to throw ENOSPC error769 vi.spyOn(fsService, 'writeTextFile').mockImplementationOnce(() => {770 const error = new Error(771 'No space left on device',772 ) as NodeJS.ErrnoException;773 error.code = 'ENOSPC';774 return Promise.reject(error);775 });776 777 const params = { file_path: filePath, content };778 const invocation = tool.build(params);779 const result = await invocation.execute(abortSignal);780 781 expect(result.error?.type).toBe(ToolErrorType.NO_SPACE_LEFT);782 expect(result.llmContent).toContain(783 `No space left on device: ${filePath} (ENOSPC)`,784 );785 expect(result.returnDisplay).toContain(786 `No space left on device: ${filePath} (ENOSPC)`,787 );788 });789 790 it('should return TARGET_IS_DIRECTORY error when write fails with EISDIR', async () => {791 const dirPath = path.join(rootDir, 'test_directory');792 const content = 'test content';793 794 // Mock fs.existsSync to return false to bypass validation795 const originalExistsSync = fs.existsSync;796 vi.spyOn(fs, 'existsSync').mockImplementation((path) => {797 if (path === dirPath) {798 return false; // Pretend directory doesn't exist to bypass validation799 }800 return originalExistsSync(path as string);801 });802 803 // Mock FileSystemService writeTextFile to throw EISDIR error804 vi.spyOn(fsService, 'writeTextFile').mockImplementationOnce(() => {805 const error = new Error('Is a directory') as NodeJS.ErrnoException;806 error.code = 'EISDIR';807 return Promise.reject(error);808 });809 810 const params = { file_path: dirPath, content };811 const invocation = tool.build(params);812 const result = await invocation.execute(abortSignal);813 814 expect(result.error?.type).toBe(ToolErrorType.TARGET_IS_DIRECTORY);815 expect(result.llmContent).toContain(816 `Target is a directory, not a file: ${dirPath} (EISDIR)`,817 );818 expect(result.returnDisplay).toContain(819 `Target is a directory, not a file: ${dirPath} (EISDIR)`,820 );821 822 vi.spyOn(fs, 'existsSync').mockImplementation(originalExistsSync);823 });824 825 it('should return FILE_WRITE_FAILURE for generic write errors', async () => {826 const filePath = path.join(rootDir, 'generic_error_file.txt');827 const content = 'test content';828 829 // Ensure fs.existsSync is not mocked for this test830 vi.restoreAllMocks();831 832 // Mock FileSystemService writeTextFile to throw generic error833 vi.spyOn(fsService, 'writeTextFile').mockImplementationOnce(() =>834 Promise.reject(new Error('Generic write error')),835 );836 837 const params = { file_path: filePath, content };838 const invocation = tool.build(params);839 const result = await invocation.execute(abortSignal);840 841 expect(result.error?.type).toBe(ToolErrorType.FILE_WRITE_FAILURE);842 expect(result.llmContent).toContain(843 'Error writing to file: Generic write error',844 );845 expect(result.returnDisplay).toContain(846 'Error writing to file: Generic write error',847 );848 });849 850 it('should include cause details for non-Node write errors', async () => {851 const filePath = path.join(rootDir, 'write_error_with_cause.txt');852 const content = 'test content';853 854 vi.restoreAllMocks();855 856 const cause = Object.assign(new Error(''), { code: 'ECONNREFUSED' });857 vi.spyOn(fsService, 'writeTextFile').mockRejectedValueOnce(858 new TypeError('fetch failed', { cause }),859 );860 861 const params = { file_path: filePath, content };862 const invocation = tool.build(params);863 const result = await invocation.execute(abortSignal);864 865 expect(result.error?.type).toBe(ToolErrorType.FILE_WRITE_FAILURE);866 expect(result.llmContent).toContain(867 'Error writing to file: fetch failed (cause: ECONNREFUSED)',868 );869 expect(result.returnDisplay).toContain(870 'Error writing to file: fetch failed (cause: ECONNREFUSED)',871 );872 });873 874 it('should surface plain object write error messages without object stringification', async () => {875 const filePath = path.join(rootDir, 'plain_object_error_file.txt');876 const content = 'test content';877 878 vi.restoreAllMocks();879 880 vi.spyOn(fsService, 'writeTextFile').mockRejectedValueOnce({881 message: 'Plain object write error',882 });883 884 const params = { file_path: filePath, content };885 const invocation = tool.build(params);886 const result = await invocation.execute(abortSignal);887 888 expect(result.error?.type).toBe(ToolErrorType.FILE_WRITE_FAILURE);889 expect(result.llmContent).toContain(890 'Error writing to file: Plain object write error',891 );892 expect(result.llmContent).not.toContain('[object Object]');893 });894 });895 896 describe('BOM preservation (Issue #1672)', () => {897 const abortSignal = new AbortController().signal;898 899 it('should preserve BOM when overwriting existing file with BOM', async () => {900 const filePath = path.join(rootDir, 'bom_file.txt');901 const originalContent = 'original content';902 const newContent = 'new content';903 904 // Create file with BOM905 fs.writeFileSync(906 filePath,907 Buffer.concat([908 Buffer.from([0xef, 0xbb, 0xbf]),909 Buffer.from(originalContent, 'utf-8'),910 ]),911 );912 seedPriorRead(filePath);913 914 // Spy on writeTextFile to verify BOM option915 const writeSpy = vi.spyOn(fsService, 'writeTextFile');916 917 const params = { file_path: filePath, content: newContent };918 const invocation = tool.build(params);919 await invocation.execute(abortSignal);920 921 // Verify writeTextFile was called with bom: true922 expect(writeSpy).toHaveBeenCalledWith({923 path: filePath,924 content: newContent,925 _meta: { bom: true, encoding: 'utf-8', lineEnding: 'lf' },926 });927 928 // Cleanup929 if (fs.existsSync(filePath)) {930 fs.unlinkSync(filePath);931 }932 });933 934 it('should not add BOM when overwriting existing file without BOM', async () => {935 const filePath = path.join(rootDir, 'no_bom_file.txt');936 const originalContent = 'original content';937 const newContent = 'new content';938 939 // Create file without BOM940 fs.writeFileSync(filePath, originalContent, 'utf-8');941 seedPriorRead(filePath);942 943 // Spy on writeTextFile to verify BOM option944 const writeSpy = vi.spyOn(fsService, 'writeTextFile');945 946 const params = { file_path: filePath, content: newContent };947 const invocation = tool.build(params);948 await invocation.execute(abortSignal);949 950 // Verify writeTextFile was called with bom: false951 expect(writeSpy).toHaveBeenCalledWith({952 path: filePath,953 content: newContent,954 _meta: { bom: false, encoding: 'utf-8', lineEnding: 'lf' },955 });956 957 // Cleanup958 if (fs.existsSync(filePath)) {959 fs.unlinkSync(filePath);960 }961 });962 963 it('should use default encoding for new files', async () => {964 const filePath = path.join(rootDir, 'new_file.txt');965 const newContent = 'new content';966 967 // Ensure file does not exist968 if (fs.existsSync(filePath)) {969 fs.unlinkSync(filePath);970 }971 972 // Spy on writeTextFile to verify BOM option973 const writeSpy = vi.spyOn(fsService, 'writeTextFile');974 975 const params = { file_path: filePath, content: newContent };976 const invocation = tool.build(params);977 await invocation.execute(abortSignal);978 979 // Verify writeTextFile was called with bom: false (default is utf-8)980 expect(writeSpy).toHaveBeenCalledWith({981 path: filePath,982 content: newContent,983 _meta: { bom: false, encoding: undefined },984 });985 986 // Cleanup987 if (fs.existsSync(filePath)) {988 fs.unlinkSync(filePath);989 }990 });991 992 it('should use BOM for new files when defaultFileEncoding is utf-8-bom', async () => {993 const filePath = path.join(rootDir, 'new_file_bom.txt');994 const newContent = 'new content';995 996 // Ensure file does not exist997 if (fs.existsSync(filePath)) {998 fs.unlinkSync(filePath);999 }1000 1001 // Mock config to return utf-8-bom1002 const originalGetDefaultFileEncoding =1003 mockConfigInternal.getDefaultFileEncoding;1004 mockConfigInternal.getDefaultFileEncoding = () => 'utf-8-bom';1005 1006 // Spy on writeTextFile to verify BOM option1007 const writeSpy = vi.spyOn(fsService, 'writeTextFile');1008 1009 const params = { file_path: filePath, content: newContent };1010 const invocation = tool.build(params);1011 await invocation.execute(abortSignal);1012 1013 // Verify writeTextFile was called with bom: true1014 expect(writeSpy).toHaveBeenCalledWith({1015 path: filePath,1016 content: newContent,1017 _meta: { bom: true, encoding: undefined },1018 });1019 1020 // Restore mock1021 mockConfigInternal.getDefaultFileEncoding =1022 originalGetDefaultFileEncoding;1023 1024 // Cleanup1025 if (fs.existsSync(filePath)) {1026 fs.unlinkSync(filePath);1027 }1028 });1029 1030 it('records a write into the FileReadCache', async () => {1031 // Symmetric with EditTool's "records a write" test: ensures1032 // ReadFile's post-write guard observes lastWriteAt and skips1033 // the file_unchanged placeholder for files this PR's tools just1034 // mutated.1035 fileReadCache.clear();1036 const filePath = path.join(rootDir, 'cache-marker.txt');1037 const params = { file_path: filePath, content: 'fresh bytes' };1038 1039 const invocation = tool.build(params);1040 const result = await invocation.execute(abortSignal);1041 expect(result.error).toBeUndefined();1042 1043 const stats = fs.statSync(filePath);1044 const status = fileReadCache.check(stats);1045 expect(status.state).toBe('fresh');1046 if (status.state === 'fresh') {1047 expect(status.entry.lastWriteAt).toBeDefined();1048 }1049 1050 if (fs.existsSync(filePath)) {1051 fs.unlinkSync(filePath);1052 }1053 });1054 });1055 1056 // Same as edit.test's wiring guard: the WriteFileTool feeds the1057 // commit-attribution singleton on success. The recordEdit call1058 // distinguishes a true file creation (`null` old content) from1059 // overwriting an existing empty file (`''` old content); these1060 // tests pin both shapes so the distinction can't drift silently.1061 describe('commit-attribution wiring', () => {1062 const abortSignal = new AbortController().signal;1063 1064 beforeEach(() => {1065 CommitAttributionService.resetInstance();1066 });1067 1068 it('records AI-originated writes in the attribution service', async () => {1069 const filePath = path.join(rootDir, 'attr_write.txt');1070 const invocation = tool.build({1071 file_path: filePath,1072 content: 'fresh content',1073 });1074 await invocation.execute(abortSignal);1075 1076 const attribution =1077 CommitAttributionService.getInstance().getFileAttribution(filePath);1078 expect(attribution).toBeDefined();1079 expect(attribution!.aiContribution).toBeGreaterThan(0);1080 // A truly new file should be flagged so deletions later in the1081 // session can be reconciled.1082 expect(attribution!.aiCreated).toBe(true);1083 1084 fs.unlinkSync(filePath);1085 });1086 1087 it('skips attribution when modified_by_user', async () => {1088 const filePath = path.join(rootDir, 'attr_skip.txt');1089 const invocation = tool.build({1090 file_path: filePath,1091 content: 'human-edited',1092 modified_by_user: true,1093 });1094 await invocation.execute(abortSignal);1095 1096 expect(1097 CommitAttributionService.getInstance().getFileAttribution(filePath),1098 ).toBeUndefined();1099 1100 fs.unlinkSync(filePath);1101 });1102 1103 it('marks aiCreated=false when overwriting an existing empty file', async () => {1104 const filePath = path.join(rootDir, 'attr_existing_empty.txt');1105 // Create an empty file first — the distinction we're guarding1106 // is that overwriting an empty existing file should NOT be1107 // counted as a creation, even though both old contents are1108 // length-0.1109 fs.writeFileSync(filePath, '', 'utf8');1110 // Prior-read enforcement (origin/main #3774) requires the file1111 // to have been Read before WriteFile can overwrite it.1112 seedPriorRead(filePath);1113 1114 const invocation = tool.build({1115 file_path: filePath,1116 content: 'overwrite content',1117 });1118 await invocation.execute(abortSignal);1119 1120 const attribution =1121 CommitAttributionService.getInstance().getFileAttribution(filePath);1122 expect(attribution).toBeDefined();1123 expect(attribution!.aiCreated).toBe(false);1124 1125 fs.unlinkSync(filePath);1126 });1127 });1128 1129 describe('prior-read enforcement', () => {1130 const abortSignal = new AbortController().signal;1131 1132 it('rejects a write that would overwrite an unread existing file', async () => {1133 const filePath = path.join(rootDir, 'enforce-overwrite.txt');1134 fs.writeFileSync(filePath, 'untouched bytes', 'utf-8');1135 // No seedPriorRead — model has not Read this file in the session.1136 1137 // Spy on readTextFile to assert enforcement runs *before* any1138 // I/O against the file's contents — see the L4 review comment.1139 const readSpy = vi.spyOn(fsService, 'readTextFile');1140 1141 const params = { file_path: filePath, content: 'clobber attempt' };1142 const result = await tool.build(params).execute(abortSignal);1143 1144 expect(result.error?.type).toBe(ToolErrorType.EDIT_REQUIRES_PRIOR_READ);1145 expect(result.error?.message).toMatch(1146 /has not been read in this session/,1147 );1148 // File must remain at its pre-call content, and the tool must1149 // not have slurped the existing bytes into memory before1150 // rejecting.1151 expect(fs.readFileSync(filePath, 'utf-8')).toBe('untouched bytes');1152 expect(readSpy).not.toHaveBeenCalled();1153 1154 readSpy.mockRestore();1155 fs.unlinkSync(filePath);1156 });1157 1158 it('allows a write after a ranged (offset/limit) read', async () => {1159 // Aligns WriteFile with EditTool and Claude Code's1160 // `readFileState`: any prior read clears enforcement. The1161 // earlier asymmetric stance (full read required for1162 // overwrite, partial OK for Edit) created a deadlock on1163 // files larger than the truncate-tool-output limit, where1164 // `read_file` without offset/limit still produced a1165 // truncated read and there was no way to satisfy the1166 // "fully read" precondition (issue #3945). The mtime/size1167 // drift check is the gate that distinguishes "model has1168 // seen current bytes" from "model has seen older bytes",1169 // and it fires identically for Edit and WriteFile.1170 const filePath = path.join(rootDir, 'enforce-ranged.txt');1171 fs.writeFileSync(filePath, 'unchanged', 'utf-8');1172 const stats = fs.statSync(filePath);1173 fileReadCache.recordRead(filePath, stats, {1174 full: false,1175 cacheable: true,1176 });1177 1178 const result = await tool1179 .build({ file_path: filePath, content: 'clobber' })1180 .execute(abortSignal);1181 expect(result.error).toBeUndefined();1182 expect(fs.readFileSync(filePath, 'utf-8')).toBe('clobber');1183 1184 fs.unlinkSync(filePath);1185 });1186 1187 it('allows a write after a truncated full read (issue #3945 deadlock fix)', async () => {1188 // Pre-fix, a `read_file` without offset/limit on a file larger1189 // than the truncate-tool-output limit recorded1190 // `lastReadWasFull: false` (the model only saw the head), and1191 // WriteFile's `requireFullRead: true` rejected the follow-up1192 // overwrite with "only been partially read … re-read without1193 // offset / limit / pages" — but a re-read produces the same1194 // truncated state, deadlocking the user. After dropping1195 // `requireFullRead` (aligning with Claude Code), the truncated1196 // read is enough to clear enforcement; the mtime/size drift1197 // check remains the gate that distinguishes "model saw current1198 // bytes" from "model saw older bytes".1199 //1200 // Coverage split: this test seeds the cache directly (mockConfig