From 9a2b39eecaf21d44506e63f19884dac6093a0e6f Mon Sep 17 00:00:00 2001 From: 0ywfe Date: Mon, 1 Jun 2026 16:17:44 +0100 Subject: [PATCH] fix(filesystem): verify write_file output landed on disk (#4138) write_file in the filesystem server resolved successfully even when no bytes reached disk on Windows. Callers (Claude Desktop and any other MCP client) received "File created successfully" and a normal structured result, with no way to detect that the file was never written. Reported in #4138 against Windows 11 with both isUsingBuiltInNodeForMcp and system Node. Repro ruled out NTFS permissions, Defender, Controlled Folder Access, OneDrive, and paths-with-spaces. The same paths work for edit_file, read_file, and list_directory; only write_file silently no-ops. The host-side root cause varies by environment and isn't fully nailed down, but the failure mode is consistent: fs.writeFile resolves with no error and nothing is on disk afterward. Rather than chase the host layer, this change closes the gap at the boundary we control by adding a post-write verification step to writeFileContent: 1. fs.stat the target file after the write completes. 2. Compare on-disk size to Buffer.byteLength(content, 'utf-8'). 3. Throw an explicit error on miss / mismatch. This converts the silent-success failure mode into a clear "write reported success but the file is missing on disk" error that callers can surface to the user instead of returning a false-positive success. The verification runs for both the fresh-write (wx flag) and atomic- rename (EEXIST) branches. The fix is platform-agnostic: silent success is a bad failure mode on any OS, and the same verification protects against partial writes, antivirus quarantine mid-write, and rename-to-wrong-path bugs in addition to the original Windows symptom. --- src/filesystem/__tests__/lib.test.ts | 37 ++++++++++++++++++++++++++-- src/filesystem/lib.ts | 22 +++++++++++++++++ 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/src/filesystem/__tests__/lib.test.ts b/src/filesystem/__tests__/lib.test.ts index e0ae61224f..6f7fde6307 100644 --- a/src/filesystem/__tests__/lib.test.ts +++ b/src/filesystem/__tests__/lib.test.ts @@ -303,10 +303,43 @@ describe('Lib Functions', () => { describe('writeFileContent', () => { it('writes file content', async () => { mockFs.writeFile.mockResolvedValueOnce(undefined); - + mockFs.stat.mockResolvedValueOnce({ size: Buffer.byteLength('new content', 'utf-8') } as any); + await writeFileContent('/test/file.txt', 'new content'); - + expect(mockFs.writeFile).toHaveBeenCalledWith('/test/file.txt', 'new content', { encoding: "utf-8", flag: 'wx' }); + expect(mockFs.stat).toHaveBeenCalledWith('/test/file.txt'); + }); + + // Regression for #4138: write_file silently returns success on Windows + // even though no bytes reach disk. The post-write stat verification turns + // that silent failure into an explicit error. + it('throws when post-write stat finds the file missing (silent write failure)', async () => { + mockFs.writeFile.mockResolvedValueOnce(undefined); + const enoent = Object.assign(new Error('ENOENT: no such file or directory'), { code: 'ENOENT' }); + mockFs.stat.mockRejectedValueOnce(enoent); + + await expect(writeFileContent('/test/file.txt', 'payload')).rejects.toThrow( + /reported success but the file is missing on disk/ + ); + }); + + it('throws when post-write stat reports a size mismatch', async () => { + mockFs.writeFile.mockResolvedValueOnce(undefined); + // Content is 7 bytes ('payload'); pretend disk shows 0 bytes. + mockFs.stat.mockResolvedValueOnce({ size: 0 } as any); + + await expect(writeFileContent('/test/file.txt', 'payload')).rejects.toThrow( + /on-disk size is 0 bytes; expected 7 bytes/ + ); + }); + + it('verifies size correctly for multibyte UTF-8 content', async () => { + const content = 'héllo 🌍'; // 12 UTF-8 bytes + mockFs.writeFile.mockResolvedValueOnce(undefined); + mockFs.stat.mockResolvedValueOnce({ size: Buffer.byteLength(content, 'utf-8') } as any); + + await expect(writeFileContent('/test/file.txt', content)).resolves.toBeUndefined(); }); }); diff --git a/src/filesystem/lib.ts b/src/filesystem/lib.ts index ce4af9f38a..c2978f7065 100644 --- a/src/filesystem/lib.ts +++ b/src/filesystem/lib.ts @@ -182,6 +182,28 @@ export async function writeFileContent(filePath: string, content: string): Promi throw error; } } + + // Post-write verification: stat the target and confirm size matches the + // UTF-8 byte length of the written content. Catches silent write failures + // where writeFile resolves successfully but the bytes never reach disk + // (observed on Windows in #4138; the underlying cause varies by host but + // the failure mode — silent success with no file on disk — is the same). + const expectedSize = Buffer.byteLength(content, 'utf-8'); + let stats: Awaited>; + try { + stats = await fs.stat(filePath); + } catch (statError) { + throw new Error( + `Write to ${filePath} reported success but the file is missing on disk. ` + + `This indicates a silent write failure in the host filesystem layer.` + ); + } + if (stats.size !== expectedSize) { + throw new Error( + `Write to ${filePath} reported success but on-disk size is ${stats.size} bytes; ` + + `expected ${expectedSize} bytes.` + ); + } }