CoolFace
Datasetpublic

basant307/AI_Governance_Project

sourceHugging Faceapache-2.0updated 2mo agoView on Hugging Face
0likes48downloads
write-file.test.ts1402 linesDownload Raw Back to tools
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

Showing the first 1,200 of 1402 lines. Download the file for the rest.

basant307/AI_Governance_Project · CoolFace