From 3371da337696fb019df2c48a5f756ed1a5a2f6ee Mon Sep 17 00:00:00 2001 From: Tony Cirigliano Date: Fri, 1 May 2026 09:25:28 -0400 Subject: [PATCH] Use safeCopyFile shim to avoid copy_file_range EPERM on WSL2 + EFS fs.copyFileSync (and the underlying libuv uv_fs_copyfile) uses Linux's copy_file_range syscall as a fast path. That syscall fails with EPERM when the destination is on a Windows volume mounted via WSL2 9P AND the destination directory has the EFS Encrypted attribute. This makes `openwolf init` and `openwolf update` unusable on any Windows-EFS path opened from WSL. Plain read+write avoids copy_file_range and works in all cases. Add safeCopyFile to utils/fs-safe.ts (matching the existing safe-write pattern) and replace all 12 fs.copyFileSync call sites in cli/init.ts and cli/update.ts. Reproduction: 1. On Windows, mark a directory EFS-encrypted (cipher /e ) 2. Open WSL2, cd into the directory via /mnt/ 3. openwolf init -> EPERM at fs.copyFileSync of OPENWOLF.md After this change: init and update succeed; new files inherit the parent's Encrypted attribute correctly via standard NTFS inheritance. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/cli/init.ts | 6 +++--- src/cli/update.ts | 22 +++++++++++----------- src/utils/fs-safe.ts | 16 ++++++++++++++++ 3 files changed, 30 insertions(+), 14 deletions(-) diff --git a/src/cli/init.ts b/src/cli/init.ts index 0414bb7..aa615ca 100644 --- a/src/cli/init.ts +++ b/src/cli/init.ts @@ -4,7 +4,7 @@ import { fileURLToPath } from "node:url"; import { execSync } from "node:child_process"; import { findProjectRoot } from "../scanner/project-root.js"; import { scanProject } from "../scanner/anatomy-scanner.js"; -import { readJSON, writeJSON, readText, writeText } from "../utils/fs-safe.js"; +import { readJSON, writeJSON, readText, writeText, safeCopyFile } from "../utils/fs-safe.js"; import { ensureDir } from "../utils/paths.js"; import { isWindows } from "../utils/platform.js"; import { registerProject } from "./registry.js"; @@ -307,7 +307,7 @@ function writeTemplateFile(templatesDir: string, wolfDir: string, file: string): const srcPath = path.join(templatesDir, file); const destPath = path.join(wolfDir, file); if (fs.existsSync(srcPath)) { - fs.copyFileSync(srcPath, destPath); + safeCopyFile(srcPath, destPath); } else { generateTemplate(destPath, file); } @@ -440,7 +440,7 @@ function copyHookScripts(wolfDir: string): void { for (const file of hookFiles) { const src = path.join(sourceDir, file); if (fs.existsSync(src)) { - fs.copyFileSync(src, path.join(hooksDir, file)); + safeCopyFile(src, path.join(hooksDir, file)); copiedAny = true; } } diff --git a/src/cli/update.ts b/src/cli/update.ts index 33cf5cd..bd3a191 100644 --- a/src/cli/update.ts +++ b/src/cli/update.ts @@ -11,7 +11,7 @@ import * as fs from "node:fs"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; import { getRegisteredProjects, registerProject, type RegisteredProject } from "./registry.js"; -import { readJSON, writeJSON, readText, writeText } from "../utils/fs-safe.js"; +import { readJSON, writeJSON, readText, writeText, safeCopyFile } from "../utils/fs-safe.js"; import { ensureDir } from "../utils/paths.js"; const __filename = fileURLToPath(import.meta.url); @@ -170,7 +170,7 @@ async function updateProject( const srcPath = path.join(templatesDir, file); const destPath = path.join(wolfDir, file); if (fs.existsSync(srcPath)) { - fs.copyFileSync(srcPath, destPath); + safeCopyFile(srcPath, destPath); } } console.log(` ✓ Templates updated (${ALWAYS_OVERWRITE.join(", ")})`); @@ -250,7 +250,7 @@ function createBackup(wolfDir: string): string { for (const file of BACKUP_FILES) { const src = path.join(wolfDir, file); if (fs.existsSync(src)) { - fs.copyFileSync(src, path.join(backupDir, file)); + safeCopyFile(src, path.join(backupDir, file)); } } @@ -264,7 +264,7 @@ function createBackup(wolfDir: string): string { for (const f of hookFiles) { const src = path.join(hooksDir, f); if (fs.statSync(src).isFile()) { - fs.copyFileSync(src, path.join(hooksBackup, f)); + safeCopyFile(src, path.join(hooksBackup, f)); } } } catch {} @@ -276,13 +276,13 @@ function createBackup(wolfDir: string): string { if (fs.existsSync(claudeSettings)) { const claudeBackup = path.join(backupDir, ".claude"); ensureDir(claudeBackup); - fs.copyFileSync(claudeSettings, path.join(claudeBackup, "settings.json")); + safeCopyFile(claudeSettings, path.join(claudeBackup, "settings.json")); } const claudeRules = path.join(projectRoot, ".claude", "rules", "openwolf.md"); if (fs.existsSync(claudeRules)) { const rulesBackup = path.join(backupDir, ".claude", "rules"); ensureDir(rulesBackup); - fs.copyFileSync(claudeRules, path.join(rulesBackup, "openwolf.md")); + safeCopyFile(claudeRules, path.join(rulesBackup, "openwolf.md")); } return backupDir; @@ -342,7 +342,7 @@ function copyHookScripts(wolfDir: string): void { for (const file of hookFiles) { const src = path.join(sourceDir, file); if (fs.existsSync(src)) { - fs.copyFileSync(src, path.join(hooksDir, file)); + safeCopyFile(src, path.join(hooksDir, file)); } } } @@ -443,7 +443,7 @@ export function restoreCommand(backupName?: string): void { // Restore files const files = fs.readdirSync(backupDir).filter(f => fs.statSync(path.join(backupDir, f)).isFile()); for (const file of files) { - fs.copyFileSync(path.join(backupDir, file), path.join(wolfDir, file)); + safeCopyFile(path.join(backupDir, file), path.join(wolfDir, file)); } // Restore hooks if present @@ -453,7 +453,7 @@ export function restoreCommand(backupName?: string): void { const hooksDir = path.join(wolfDir, "hooks"); ensureDir(hooksDir); for (const f of hookFiles) { - fs.copyFileSync(path.join(hooksBackup, f), path.join(hooksDir, f)); + safeCopyFile(path.join(hooksBackup, f), path.join(hooksDir, f)); } } @@ -465,13 +465,13 @@ export function restoreCommand(backupName?: string): void { if (fs.existsSync(settingsBackup)) { const dest = path.join(projectRoot, ".claude", "settings.json"); ensureDir(path.dirname(dest)); - fs.copyFileSync(settingsBackup, dest); + safeCopyFile(settingsBackup, dest); } const rulesBackup = path.join(claudeBackup, "rules", "openwolf.md"); if (fs.existsSync(rulesBackup)) { const dest = path.join(projectRoot, ".claude", "rules", "openwolf.md"); ensureDir(path.dirname(dest)); - fs.copyFileSync(rulesBackup, dest); + safeCopyFile(rulesBackup, dest); } } diff --git a/src/utils/fs-safe.ts b/src/utils/fs-safe.ts index 49d11f5..fb8ff24 100644 --- a/src/utils/fs-safe.ts +++ b/src/utils/fs-safe.ts @@ -60,3 +60,19 @@ export function appendText(filePath: string, content: string): void { } fs.appendFileSync(filePath, content, "utf-8"); } + +// Drop-in replacement for fs.copyFileSync that works around a libuv/9P +// limitation: fs.copyFileSync uses the copy_file_range syscall on Linux, +// which fails with EPERM when writing to EFS-encrypted directories on +// Windows volumes mounted via WSL2 9P. Plain read+write bypasses +// copy_file_range and works in all cases. +export function safeCopyFile(src: string, dest: string): void { + const dir = path.dirname(dest); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + fs.writeFileSync(dest, fs.readFileSync(src)); + try { + fs.chmodSync(dest, fs.statSync(src).mode); + } catch {} +}