fix(electron): work around Node.js cpSync Unicode crash on Windows (#686)
This commit is contained in:
committed by
GitHub
Unverified
parent
537a85c4d1
commit
aa98e59317
@@ -1,6 +1,6 @@
|
|||||||
import { app } from 'electron';
|
import { app } from 'electron';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { existsSync, readFileSync, cpSync, mkdirSync, rmSync } from 'fs';
|
import { existsSync, readFileSync, mkdirSync, rmSync } from 'fs';
|
||||||
import { homedir } from 'os';
|
import { homedir } from 'os';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ import { buildProxyEnv, resolveProxySettings } from '../utils/proxy';
|
|||||||
import { syncProxyConfigToOpenClaw } from '../utils/openclaw-proxy';
|
import { syncProxyConfigToOpenClaw } from '../utils/openclaw-proxy';
|
||||||
import { logger } from '../utils/logger';
|
import { logger } from '../utils/logger';
|
||||||
import { prependPathEntry } from '../utils/env-path';
|
import { prependPathEntry } from '../utils/env-path';
|
||||||
import { copyPluginFromNodeModules, fixupPluginManifest } from '../utils/plugin-install';
|
import { copyPluginFromNodeModules, fixupPluginManifest, cpSyncSafe } from '../utils/plugin-install';
|
||||||
|
|
||||||
export interface GatewayLaunchContext {
|
export interface GatewayLaunchContext {
|
||||||
appSettings: Awaited<ReturnType<typeof getAllSettings>>;
|
appSettings: Awaited<ReturnType<typeof getAllSettings>>;
|
||||||
@@ -124,7 +124,7 @@ function ensureConfiguredPluginsUpgraded(configuredChannels: string[]): void {
|
|||||||
try {
|
try {
|
||||||
mkdirSync(fsPath(join(homedir(), '.openclaw', 'extensions')), { recursive: true });
|
mkdirSync(fsPath(join(homedir(), '.openclaw', 'extensions')), { recursive: true });
|
||||||
rmSync(fsPath(targetDir), { recursive: true, force: true });
|
rmSync(fsPath(targetDir), { recursive: true, force: true });
|
||||||
cpSync(fsPath(bundledDir), fsPath(targetDir), { recursive: true, dereference: true });
|
cpSyncSafe(bundledDir, targetDir);
|
||||||
fixupPluginManifest(targetDir);
|
fixupPluginManifest(targetDir);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.warn(`[plugin] Failed to ${isInstalled ? 'auto-upgrade' : 'install'} ${channelType} plugin:`, err);
|
logger.warn(`[plugin] Failed to ${isInstalled ? 'auto-upgrade' : 'install'} ${channelType} plugin:`, err);
|
||||||
|
|||||||
@@ -7,7 +7,8 @@
|
|||||||
*/
|
*/
|
||||||
import { app } from 'electron';
|
import { app } from 'electron';
|
||||||
import path from 'node:path';
|
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 { homedir } from 'node:os';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { logger } from './logger';
|
import { logger } from './logger';
|
||||||
@@ -29,6 +30,66 @@ function fsPath(filePath: string): string {
|
|||||||
return normalizeFsPathForWindows(filePath);
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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 {
|
function asErrnoException(error: unknown): NodeJS.ErrnoException | null {
|
||||||
if (error && typeof error === 'object') {
|
if (error && typeof error === 'object') {
|
||||||
return error as NodeJS.ErrnoException;
|
return error as NodeJS.ErrnoException;
|
||||||
@@ -236,7 +297,7 @@ export function copyPluginFromNodeModules(npmPkgPath: string, targetDir: string,
|
|||||||
// 1. Copy plugin package itself
|
// 1. Copy plugin package itself
|
||||||
rmSync(fsPath(targetDir), { recursive: true, force: true });
|
rmSync(fsPath(targetDir), { recursive: true, force: true });
|
||||||
mkdirSync(fsPath(targetDir), { recursive: 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
|
// 2. Collect transitive deps from pnpm virtual store
|
||||||
const rootVirtualNM = findParentNodeModules(realPath);
|
const rootVirtualNM = findParentNodeModules(realPath);
|
||||||
@@ -287,7 +348,7 @@ export function copyPluginFromNodeModules(npmPkgPath: string, targetDir: string,
|
|||||||
const dest = join(outputNM, pkgName);
|
const dest = join(outputNM, pkgName);
|
||||||
try {
|
try {
|
||||||
mkdirSync(fsPath(path.dirname(dest)), { recursive: true });
|
mkdirSync(fsPath(path.dirname(dest)), { recursive: true });
|
||||||
cpSync(fsPath(depRealPath), fsPath(dest), { recursive: true, dereference: true });
|
cpSyncSafe(depRealPath, dest);
|
||||||
} catch { /* skip individual dep failures */ }
|
} catch { /* skip individual dep failures */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -331,7 +392,7 @@ export function ensurePluginInstalled(
|
|||||||
try {
|
try {
|
||||||
mkdirSync(fsPath(extensionsRoot), { recursive: true });
|
mkdirSync(fsPath(extensionsRoot), { recursive: true });
|
||||||
rmSync(fsPath(targetDir), { recursive: true, force: 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')))) {
|
if (!existsSync(fsPath(join(targetDir, 'openclaw.plugin.json')))) {
|
||||||
return { installed: false, warning: `Failed to install ${pluginLabel} plugin mirror (manifest missing).` };
|
return { installed: false, warning: `Failed to install ${pluginLabel} plugin mirror (manifest missing).` };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,13 +5,14 @@
|
|||||||
*
|
*
|
||||||
* All file I/O uses async fs/promises to avoid blocking the main thread.
|
* 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 { existsSync } from 'fs';
|
||||||
import { constants } from 'fs';
|
import { constants } from 'fs';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { homedir } from 'os';
|
import { homedir } from 'os';
|
||||||
import { getOpenClawDir, getResourcesDir } from './paths';
|
import { getOpenClawDir, getResourcesDir } from './paths';
|
||||||
import { logger } from './logger';
|
import { logger } from './logger';
|
||||||
|
import { cpAsyncSafe } from './plugin-install';
|
||||||
import { withConfigLock } from './config-mutex';
|
import { withConfigLock } from './config-mutex';
|
||||||
|
|
||||||
const OPENCLAW_CONFIG_PATH = join(homedir(), '.openclaw', 'openclaw.json');
|
const OPENCLAW_CONFIG_PATH = join(homedir(), '.openclaw', 'openclaw.json');
|
||||||
@@ -220,7 +221,7 @@ export async function ensureBuiltinSkillsInstalled(): Promise<void> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await mkdir(targetDir, { recursive: true });
|
await mkdir(targetDir, { recursive: true });
|
||||||
await cp(sourceDir, targetDir, { recursive: true });
|
await cpAsyncSafe(sourceDir, targetDir);
|
||||||
logger.info(`Installed built-in skill: ${slug} -> ${targetDir}`);
|
logger.info(`Installed built-in skill: ${slug} -> ${targetDir}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn(`Failed to install built-in skill ${slug}:`, error);
|
logger.warn(`Failed to install built-in skill ${slug}:`, error);
|
||||||
@@ -362,7 +363,7 @@ export async function ensurePreinstalledSkillsInstalled(): Promise<void> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await mkdir(targetDir, { recursive: true });
|
await mkdir(targetDir, { recursive: true });
|
||||||
await cp(sourceDir, targetDir, { recursive: true, force: true });
|
await cpAsyncSafe(sourceDir, targetDir);
|
||||||
const markerPayload: PreinstalledMarker = {
|
const markerPayload: PreinstalledMarker = {
|
||||||
source: 'clawx-preinstalled',
|
source: 'clawx-preinstalled',
|
||||||
slug: spec.slug,
|
slug: spec.slug,
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|||||||
const {
|
const {
|
||||||
mockExistsSync,
|
mockExistsSync,
|
||||||
mockCpSync,
|
mockCpSync,
|
||||||
|
mockCopyFileSync,
|
||||||
|
mockStatSync,
|
||||||
mockMkdirSync,
|
mockMkdirSync,
|
||||||
mockRmSync,
|
mockRmSync,
|
||||||
mockReadFileSync,
|
mockReadFileSync,
|
||||||
@@ -16,6 +18,8 @@ const {
|
|||||||
} = vi.hoisted(() => ({
|
} = vi.hoisted(() => ({
|
||||||
mockExistsSync: vi.fn(),
|
mockExistsSync: vi.fn(),
|
||||||
mockCpSync: vi.fn(),
|
mockCpSync: vi.fn(),
|
||||||
|
mockCopyFileSync: vi.fn(),
|
||||||
|
mockStatSync: vi.fn(() => ({ isDirectory: () => false })),
|
||||||
mockMkdirSync: vi.fn(),
|
mockMkdirSync: vi.fn(),
|
||||||
mockRmSync: vi.fn(),
|
mockRmSync: vi.fn(),
|
||||||
mockReadFileSync: vi.fn(),
|
mockReadFileSync: vi.fn(),
|
||||||
@@ -39,6 +43,8 @@ vi.mock('node:fs', async () => {
|
|||||||
...actual,
|
...actual,
|
||||||
existsSync: mockExistsSync,
|
existsSync: mockExistsSync,
|
||||||
cpSync: mockCpSync,
|
cpSync: mockCpSync,
|
||||||
|
copyFileSync: mockCopyFileSync,
|
||||||
|
statSync: mockStatSync,
|
||||||
mkdirSync: mockMkdirSync,
|
mkdirSync: mockMkdirSync,
|
||||||
rmSync: mockRmSync,
|
rmSync: mockRmSync,
|
||||||
readFileSync: mockReadFileSync,
|
readFileSync: mockReadFileSync,
|
||||||
@@ -52,6 +58,17 @@ vi.mock('node:fs', async () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
vi.mock('node:fs/promises', async () => {
|
||||||
|
const actual = await vi.importActual<typeof import('node:fs/promises')>('node:fs/promises');
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
readdir: vi.fn(),
|
||||||
|
stat: vi.fn(),
|
||||||
|
copyFile: vi.fn(),
|
||||||
|
mkdir: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
vi.mock('node:os', () => ({
|
vi.mock('node:os', () => ({
|
||||||
homedir: () => mockHomedir(),
|
homedir: () => mockHomedir(),
|
||||||
default: {
|
default: {
|
||||||
@@ -118,10 +135,15 @@ describe('plugin installer diagnostics', () => {
|
|||||||
const sourceManifestSuffix = 'Program Files\\ClawX\\resources\\openclaw-plugins\\wecom\\openclaw.plugin.json';
|
const sourceManifestSuffix = 'Program Files\\ClawX\\resources\\openclaw-plugins\\wecom\\openclaw.plugin.json';
|
||||||
|
|
||||||
mockExistsSync.mockImplementation((input: string) => String(input).includes(sourceManifestSuffix));
|
mockExistsSync.mockImplementation((input: string) => String(input).includes(sourceManifestSuffix));
|
||||||
mockCpSync.mockImplementation(() => {
|
// On win32, cpSyncSafe uses _copyDirSyncRecursive (readdirSync) instead of cpSync.
|
||||||
const error = new Error('path too long') as NodeJS.ErrnoException;
|
// Simulate copy failure by making readdirSync throw during directory traversal.
|
||||||
error.code = 'ENAMETOOLONG';
|
mockReaddirSync.mockImplementation((_path: string, opts?: unknown) => {
|
||||||
throw error;
|
if (opts && typeof opts === 'object' && 'withFileTypes' in (opts as Record<string, unknown>)) {
|
||||||
|
const error = new Error('path too long') as NodeJS.ErrnoException;
|
||||||
|
error.code = 'ENAMETOOLONG';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return [];
|
||||||
});
|
});
|
||||||
|
|
||||||
const { ensurePluginInstalled } = await import('@electron/utils/plugin-install');
|
const { ensurePluginInstalled } = await import('@electron/utils/plugin-install');
|
||||||
@@ -132,10 +154,16 @@ describe('plugin installer diagnostics', () => {
|
|||||||
warning: 'Failed to install bundled WeCom plugin mirror',
|
warning: 'Failed to install bundled WeCom plugin mirror',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mockCpSync).toHaveBeenCalledTimes(2);
|
// On win32, cpSyncSafe walks the directory via readdirSync (with withFileTypes)
|
||||||
const [firstSourcePath, firstTargetPath] = mockCpSync.mock.calls[0] as [string, string];
|
const copyAttempts = mockReaddirSync.mock.calls.filter(
|
||||||
expect(firstSourcePath.startsWith('\\\\?\\')).toBe(true);
|
(call: unknown[]) => {
|
||||||
expect(firstTargetPath.startsWith('\\\\?\\')).toBe(true);
|
const opts = call[1];
|
||||||
|
return opts && typeof opts === 'object' && 'withFileTypes' in (opts as Record<string, unknown>);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(copyAttempts).toHaveLength(2); // initial + 1 retry
|
||||||
|
const firstSrcPath = String(copyAttempts[0][0]);
|
||||||
|
expect(firstSrcPath.startsWith('\\\\?\\')).toBe(true);
|
||||||
|
|
||||||
expect(mockLoggerWarn).toHaveBeenCalledWith(
|
expect(mockLoggerWarn).toHaveBeenCalledWith(
|
||||||
'[plugin] Bundled mirror install failed for WeCom',
|
'[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';
|
const sourceManifestSuffix = 'Program Files\\ClawX\\resources\\openclaw-plugins\\wecom\\openclaw.plugin.json';
|
||||||
|
|
||||||
mockExistsSync.mockImplementation((input: string) => String(input).includes(sourceManifestSuffix));
|
mockExistsSync.mockImplementation((input: string) => String(input).includes(sourceManifestSuffix));
|
||||||
mockCpSync.mockImplementation(() => {
|
// On win32, cpSyncSafe uses _copyDirSyncRecursive (readdirSync) instead of cpSync.
|
||||||
const error = new Error('access denied') as NodeJS.ErrnoException;
|
mockReaddirSync.mockImplementation((_path: string, opts?: unknown) => {
|
||||||
error.code = 'EPERM';
|
if (opts && typeof opts === 'object' && 'withFileTypes' in (opts as Record<string, unknown>)) {
|
||||||
throw error;
|
const error = new Error('access denied') as NodeJS.ErrnoException;
|
||||||
|
error.code = 'EPERM';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return [];
|
||||||
});
|
});
|
||||||
|
|
||||||
const { ensurePluginInstalled } = await import('@electron/utils/plugin-install');
|
const { ensurePluginInstalled } = await import('@electron/utils/plugin-install');
|
||||||
|
|||||||
Reference in New Issue
Block a user