diff --git a/electron/gateway/config-sync.ts b/electron/gateway/config-sync.ts index a90bfdd6e..2abdde4fb 100644 --- a/electron/gateway/config-sync.ts +++ b/electron/gateway/config-sync.ts @@ -1,6 +1,6 @@ import { app } from 'electron'; import path from 'path'; -import { existsSync, readFileSync, cpSync, mkdirSync, rmSync } from 'fs'; +import { existsSync, readFileSync, mkdirSync, rmSync } from 'fs'; import { homedir } from 'os'; import { join } from 'path'; @@ -26,7 +26,7 @@ import { buildProxyEnv, resolveProxySettings } from '../utils/proxy'; import { syncProxyConfigToOpenClaw } from '../utils/openclaw-proxy'; import { logger } from '../utils/logger'; import { prependPathEntry } from '../utils/env-path'; -import { copyPluginFromNodeModules, fixupPluginManifest } from '../utils/plugin-install'; +import { copyPluginFromNodeModules, fixupPluginManifest, cpSyncSafe } from '../utils/plugin-install'; export interface GatewayLaunchContext { appSettings: Awaited>; @@ -124,7 +124,7 @@ function ensureConfiguredPluginsUpgraded(configuredChannels: string[]): void { try { mkdirSync(fsPath(join(homedir(), '.openclaw', 'extensions')), { recursive: true }); rmSync(fsPath(targetDir), { recursive: true, force: true }); - cpSync(fsPath(bundledDir), fsPath(targetDir), { recursive: true, dereference: true }); + cpSyncSafe(bundledDir, targetDir); fixupPluginManifest(targetDir); } catch (err) { logger.warn(`[plugin] Failed to ${isInstalled ? 'auto-upgrade' : 'install'} ${channelType} plugin:`, err); diff --git a/electron/utils/plugin-install.ts b/electron/utils/plugin-install.ts index b50d0d0c2..1c5daac33 100644 --- a/electron/utils/plugin-install.ts +++ b/electron/utils/plugin-install.ts @@ -7,7 +7,8 @@ */ import { app } from 'electron'; import path from 'node:path'; -import { existsSync, cpSync, mkdirSync, rmSync, readFileSync, writeFileSync, readdirSync, realpathSync } from 'node:fs'; +import { existsSync, cpSync, copyFileSync, statSync, mkdirSync, rmSync, readFileSync, writeFileSync, readdirSync, realpathSync } from 'node:fs'; +import { readdir, stat, copyFile, mkdir } from 'node:fs/promises'; import { homedir } from 'node:os'; import { join } from 'node:path'; import { logger } from './logger'; @@ -29,6 +30,66 @@ function fsPath(filePath: string): string { return normalizeFsPathForWindows(filePath); } +/** + * Unicode-safe recursive directory copy. + * + * Node.js `cpSync` / `cp` crash on Windows when paths contain non-ASCII + * characters such as Chinese (nodejs/node#54476). On Windows we fall back + * to a manual recursive walk using `copyFileSync` which is unaffected. + */ +export function cpSyncSafe(src: string, dest: string): void { + if (process.platform !== 'win32') { + cpSync(fsPath(src), fsPath(dest), { recursive: true, dereference: true }); + return; + } + // Windows: manual recursive copy with per-file copyFileSync + _copyDirSyncRecursive(fsPath(src), fsPath(dest)); +} + +function _copyDirSyncRecursive(src: string, dest: string): void { + mkdirSync(dest, { recursive: true }); + const entries = readdirSync(src, { withFileTypes: true }); + for (const entry of entries) { + const srcChild = join(src, entry.name); + const destChild = join(dest, entry.name); + // Dereference symlinks: use statSync (follows links) instead of lstatSync + const info = statSync(srcChild); + if (info.isDirectory()) { + _copyDirSyncRecursive(srcChild, destChild); + } else { + copyFileSync(srcChild, destChild); + } + } +} + +/** + * Async variant of `cpSyncSafe` for use with fs/promises. + */ +export async function cpAsyncSafe(src: string, dest: string): Promise { + if (process.platform !== 'win32') { + const { cp } = await import('node:fs/promises'); + await cp(fsPath(src), fsPath(dest), { recursive: true, dereference: true }); + return; + } + // Windows: manual recursive copy with per-file copyFile + await _copyDirAsyncRecursive(fsPath(src), fsPath(dest)); +} + +async function _copyDirAsyncRecursive(src: string, dest: string): Promise { + await mkdir(dest, { recursive: true }); + const entries = await readdir(src, { withFileTypes: true }); + for (const entry of entries) { + const srcChild = join(src, entry.name); + const destChild = join(dest, entry.name); + const info = await stat(srcChild); + if (info.isDirectory()) { + await _copyDirAsyncRecursive(srcChild, destChild); + } else { + await copyFile(srcChild, destChild); + } + } +} + function asErrnoException(error: unknown): NodeJS.ErrnoException | null { if (error && typeof error === 'object') { return error as NodeJS.ErrnoException; @@ -236,7 +297,7 @@ export function copyPluginFromNodeModules(npmPkgPath: string, targetDir: string, // 1. Copy plugin package itself rmSync(fsPath(targetDir), { recursive: true, force: true }); mkdirSync(fsPath(targetDir), { recursive: true }); - cpSync(fsPath(realPath), fsPath(targetDir), { recursive: true, dereference: true }); + cpSyncSafe(realPath, targetDir); // 2. Collect transitive deps from pnpm virtual store const rootVirtualNM = findParentNodeModules(realPath); @@ -287,7 +348,7 @@ export function copyPluginFromNodeModules(npmPkgPath: string, targetDir: string, const dest = join(outputNM, pkgName); try { mkdirSync(fsPath(path.dirname(dest)), { recursive: true }); - cpSync(fsPath(depRealPath), fsPath(dest), { recursive: true, dereference: true }); + cpSyncSafe(depRealPath, dest); } catch { /* skip individual dep failures */ } } @@ -331,7 +392,7 @@ export function ensurePluginInstalled( try { mkdirSync(fsPath(extensionsRoot), { recursive: true }); rmSync(fsPath(targetDir), { recursive: true, force: true }); - cpSync(fsPath(sourceDir), fsPath(targetDir), { recursive: true, dereference: true }); + cpSyncSafe(sourceDir, targetDir); if (!existsSync(fsPath(join(targetDir, 'openclaw.plugin.json')))) { return { installed: false, warning: `Failed to install ${pluginLabel} plugin mirror (manifest missing).` }; } diff --git a/electron/utils/skill-config.ts b/electron/utils/skill-config.ts index 886b83840..b1b696e49 100644 --- a/electron/utils/skill-config.ts +++ b/electron/utils/skill-config.ts @@ -5,13 +5,14 @@ * * All file I/O uses async fs/promises to avoid blocking the main thread. */ -import { readFile, writeFile, access, cp, mkdir } from 'fs/promises'; +import { readFile, writeFile, access, mkdir } from 'fs/promises'; import { existsSync } from 'fs'; import { constants } from 'fs'; import { join } from 'path'; import { homedir } from 'os'; import { getOpenClawDir, getResourcesDir } from './paths'; import { logger } from './logger'; +import { cpAsyncSafe } from './plugin-install'; import { withConfigLock } from './config-mutex'; const OPENCLAW_CONFIG_PATH = join(homedir(), '.openclaw', 'openclaw.json'); @@ -220,7 +221,7 @@ export async function ensureBuiltinSkillsInstalled(): Promise { try { await mkdir(targetDir, { recursive: true }); - await cp(sourceDir, targetDir, { recursive: true }); + await cpAsyncSafe(sourceDir, targetDir); logger.info(`Installed built-in skill: ${slug} -> ${targetDir}`); } catch (error) { logger.warn(`Failed to install built-in skill ${slug}:`, error); @@ -362,7 +363,7 @@ export async function ensurePreinstalledSkillsInstalled(): Promise { try { await mkdir(targetDir, { recursive: true }); - await cp(sourceDir, targetDir, { recursive: true, force: true }); + await cpAsyncSafe(sourceDir, targetDir); const markerPayload: PreinstalledMarker = { source: 'clawx-preinstalled', slug: spec.slug, diff --git a/tests/unit/plugin-install.test.ts b/tests/unit/plugin-install.test.ts index 1f74a5431..4ecb8cec7 100644 --- a/tests/unit/plugin-install.test.ts +++ b/tests/unit/plugin-install.test.ts @@ -3,6 +3,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const { mockExistsSync, mockCpSync, + mockCopyFileSync, + mockStatSync, mockMkdirSync, mockRmSync, mockReadFileSync, @@ -16,6 +18,8 @@ const { } = vi.hoisted(() => ({ mockExistsSync: vi.fn(), mockCpSync: vi.fn(), + mockCopyFileSync: vi.fn(), + mockStatSync: vi.fn(() => ({ isDirectory: () => false })), mockMkdirSync: vi.fn(), mockRmSync: vi.fn(), mockReadFileSync: vi.fn(), @@ -39,6 +43,8 @@ vi.mock('node:fs', async () => { ...actual, existsSync: mockExistsSync, cpSync: mockCpSync, + copyFileSync: mockCopyFileSync, + statSync: mockStatSync, mkdirSync: mockMkdirSync, rmSync: mockRmSync, readFileSync: mockReadFileSync, @@ -52,6 +58,17 @@ vi.mock('node:fs', async () => { }; }); +vi.mock('node:fs/promises', async () => { + const actual = await vi.importActual('node:fs/promises'); + return { + ...actual, + readdir: vi.fn(), + stat: vi.fn(), + copyFile: vi.fn(), + mkdir: vi.fn(), + }; +}); + vi.mock('node:os', () => ({ homedir: () => mockHomedir(), default: { @@ -118,10 +135,15 @@ describe('plugin installer diagnostics', () => { const sourceManifestSuffix = 'Program Files\\ClawX\\resources\\openclaw-plugins\\wecom\\openclaw.plugin.json'; mockExistsSync.mockImplementation((input: string) => String(input).includes(sourceManifestSuffix)); - mockCpSync.mockImplementation(() => { - const error = new Error('path too long') as NodeJS.ErrnoException; - error.code = 'ENAMETOOLONG'; - throw error; + // On win32, cpSyncSafe uses _copyDirSyncRecursive (readdirSync) instead of cpSync. + // Simulate copy failure by making readdirSync throw during directory traversal. + mockReaddirSync.mockImplementation((_path: string, opts?: unknown) => { + if (opts && typeof opts === 'object' && 'withFileTypes' in (opts as Record)) { + const error = new Error('path too long') as NodeJS.ErrnoException; + error.code = 'ENAMETOOLONG'; + throw error; + } + return []; }); const { ensurePluginInstalled } = await import('@electron/utils/plugin-install'); @@ -132,10 +154,16 @@ describe('plugin installer diagnostics', () => { warning: 'Failed to install bundled WeCom plugin mirror', }); - expect(mockCpSync).toHaveBeenCalledTimes(2); - const [firstSourcePath, firstTargetPath] = mockCpSync.mock.calls[0] as [string, string]; - expect(firstSourcePath.startsWith('\\\\?\\')).toBe(true); - expect(firstTargetPath.startsWith('\\\\?\\')).toBe(true); + // On win32, cpSyncSafe walks the directory via readdirSync (with withFileTypes) + const copyAttempts = mockReaddirSync.mock.calls.filter( + (call: unknown[]) => { + const opts = call[1]; + return opts && typeof opts === 'object' && 'withFileTypes' in (opts as Record); + }, + ); + expect(copyAttempts).toHaveLength(2); // initial + 1 retry + const firstSrcPath = String(copyAttempts[0][0]); + expect(firstSrcPath.startsWith('\\\\?\\')).toBe(true); expect(mockLoggerWarn).toHaveBeenCalledWith( '[plugin] Bundled mirror install failed for WeCom', @@ -160,10 +188,14 @@ describe('plugin installer diagnostics', () => { const sourceManifestSuffix = 'Program Files\\ClawX\\resources\\openclaw-plugins\\wecom\\openclaw.plugin.json'; mockExistsSync.mockImplementation((input: string) => String(input).includes(sourceManifestSuffix)); - mockCpSync.mockImplementation(() => { - const error = new Error('access denied') as NodeJS.ErrnoException; - error.code = 'EPERM'; - throw error; + // On win32, cpSyncSafe uses _copyDirSyncRecursive (readdirSync) instead of cpSync. + mockReaddirSync.mockImplementation((_path: string, opts?: unknown) => { + if (opts && typeof opts === 'object' && 'withFileTypes' in (opts as Record)) { + const error = new Error('access denied') as NodeJS.ErrnoException; + error.code = 'EPERM'; + throw error; + } + return []; }); const { ensurePluginInstalled } = await import('@electron/utils/plugin-install');