From b054b7c21a5a357309a98c8cc6c6cf5620243810 Mon Sep 17 00:00:00 2001 From: Charlie Tonneslan Date: Wed, 18 Mar 2026 11:23:40 -0400 Subject: [PATCH] fix(filesystem): handle Windows EPERM on file rename when target is locked On Windows, fs.rename() throws EPERM when the target file is locked by another process (e.g., open in VS Code or Notepad). This breaks both write_file and edit_file operations. Added an atomicReplace() helper that falls back to copyFile + unlink on Windows when rename fails with EPERM. This preserves the atomic write semantics on Linux/macOS while working around the Windows file locking limitation. Fixes #3199 --- src/filesystem/lib.ts | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/src/filesystem/lib.ts b/src/filesystem/lib.ts index 17e4654cd5..d6cfd74b52 100644 --- a/src/filesystem/lib.ts +++ b/src/filesystem/lib.ts @@ -7,6 +7,24 @@ import { minimatch } from 'minimatch'; import { normalizePath, expandHome } from './path-utils.js'; import { isPathWithinAllowedDirectories } from './path-validation.js'; +/** + * Atomically replace a file using rename, with a Windows fallback. + * On Windows, fs.rename() throws EPERM when the target file is locked + * (e.g., open in an editor). Fall back to copyFile + unlink in that case. + */ +async function atomicReplace(tempPath: string, targetPath: string): Promise { + try { + await fs.rename(tempPath, targetPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'EPERM' && process.platform === 'win32') { + await fs.copyFile(tempPath, targetPath); + await fs.unlink(tempPath); + } else { + throw error; + } + } +} + // Global allowed directories - set by the main module let allowedDirectories: string[] = []; @@ -171,7 +189,7 @@ export async function writeFileContent(filePath: string, content: string): Promi const tempPath = `${filePath}.${randomBytes(16).toString('hex')}.tmp`; try { await fs.writeFile(tempPath, content, 'utf-8'); - await fs.rename(tempPath, filePath); + await atomicReplace(tempPath, filePath); } catch (renameError) { try { await fs.unlink(tempPath); @@ -269,7 +287,7 @@ export async function applyFileEdits( const tempPath = `${filePath}.${randomBytes(16).toString('hex')}.tmp`; try { await fs.writeFile(tempPath, modifiedContent, 'utf-8'); - await fs.rename(tempPath, filePath); + await atomicReplace(tempPath, filePath); } catch (error) { try { await fs.unlink(tempPath);