fix(gateway): fall back to junction when symlink unavailable on Windows

ensureExtensionDepsResolvable called symlinkSync without a type argument
and without path normalization. On Windows without Developer Mode or
admin rights, plain symlinkSync throws EPERM; the failure was silently
swallowed, leaving extension-owned packages unresolvable from shared
dist/ chunks and breaking gateway startup.

Extract the link logic into electron/gateway/fs-link.ts:
- linkDirSafe prefers junction on Windows (works without elevation),
  falls back to a plain dir symlink only if junction creation fails
  (e.g. cross-volume).
- normalizeFsPath centralizes the \\?\ extended-length + UNC prefixing
  that was previously an inline helper in config-sync.ts.

Also drop the now-redundant inline fsPath helper in config-sync.ts and
replace the two bare symlinkSync calls with linkDirSafe.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
This commit is contained in:
Haze
2026-04-24 17:11:52 +08:00
parent 6bacbd964d
commit efa76b37d3
3 changed files with 192 additions and 15 deletions

View File

@@ -1,20 +1,9 @@
import { app } from 'electron';
import path from 'path';
import { existsSync, readFileSync, mkdirSync, readdirSync, rmSync, symlinkSync } from 'fs';
import { existsSync, readFileSync, mkdirSync, readdirSync, rmSync } from 'fs';
import { homedir } from 'os';
import { join } from 'path';
function fsPath(filePath: string): string {
if (process.platform !== 'win32') return filePath;
if (!filePath) return filePath;
if (filePath.startsWith('\\\\?\\')) return filePath;
const windowsPath = filePath.replace(/\//g, '\\');
if (!path.win32.isAbsolute(windowsPath)) return windowsPath;
if (windowsPath.startsWith('\\\\')) {
return `\\\\?\\UNC\\${windowsPath.slice(2)}`;
}
return `\\\\?\\${windowsPath}`;
}
import { linkDirSafe, normalizeFsPath as fsPath } from './fs-link';
import { getAllSettings } from '../utils/store';
import { getApiKey, getDefaultProvider, getProvider } from '../utils/secure-storage';
import { getProviderEnvVar, getKeyableProviderTypes } from '../utils/provider-registry';
@@ -245,7 +234,7 @@ function ensureExtensionDepsResolvable(openclawDir: string): void {
if (existsSync(dest)) continue;
try {
mkdirSync(join(topNM, pkg.name), { recursive: true });
symlinkSync(join(scopeDir, sub.name), dest);
linkDirSafe(join(scopeDir, sub.name), dest);
linkedCount++;
} catch { /* skip on error — non-fatal */ }
}
@@ -254,7 +243,7 @@ function ensureExtensionDepsResolvable(openclawDir: string): void {
if (existsSync(dest)) continue;
try {
mkdirSync(topNM, { recursive: true });
symlinkSync(join(extNM, pkg.name), dest);
linkDirSafe(join(extNM, pkg.name), dest);
linkedCount++;
} catch { /* skip on error — non-fatal */ }
}

View File

@@ -0,0 +1,47 @@
import { symlinkSync } from 'fs';
import path from 'path';
/**
* Normalize a filesystem path for the current platform. On Windows, convert
* forward slashes to backslashes and apply the `\\?\` extended-length prefix
* for absolute paths so long paths are handled correctly. On POSIX, return
* the path unchanged.
*/
export function normalizeFsPath(filePath: string): string {
if (process.platform !== 'win32') return filePath;
if (!filePath) return filePath;
if (filePath.startsWith('\\\\?\\')) return filePath;
const windowsPath = filePath.replace(/\//g, '\\');
if (!path.win32.isAbsolute(windowsPath)) return windowsPath;
if (windowsPath.startsWith('\\\\')) {
return `\\\\?\\UNC\\${windowsPath.slice(2)}`;
}
return `\\\\?\\${windowsPath}`;
}
/**
* Create a directory link from `src` to `dest`.
*
* On POSIX uses a regular symlink. On Windows prefers a junction (which does
* not require Developer Mode or administrator privileges) and falls back to
* a regular symlink only if the junction attempt fails.
*
* Throws on POSIX when symlink creation fails. On Windows, both attempts
* failing will throw the symlink error — callers guard with try/catch when
* link creation is non-fatal (e.g. optional extension dependency linking).
*/
export function linkDirSafe(src: string, dest: string): void {
const isWin = process.platform === 'win32';
const srcP = normalizeFsPath(src);
const destP = normalizeFsPath(dest);
if (!isWin) {
symlinkSync(srcP, destP, 'dir');
return;
}
try {
symlinkSync(srcP, destP, 'junction');
} catch {
// Junction failed (e.g. cross-volume). Try a symlink as a last resort.
symlinkSync(srcP, destP, 'dir');
}
}

141
tests/unit/fs-link.test.ts Normal file
View File

@@ -0,0 +1,141 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const { symlinkSyncMock } = vi.hoisted(() => ({ symlinkSyncMock: vi.fn() }));
vi.mock('fs', async () => {
const actual = await vi.importActual<typeof import('fs')>('fs');
const mocked = {
...actual,
symlinkSync: (...args: unknown[]) => symlinkSyncMock(...args),
};
return { ...mocked, default: mocked };
});
vi.mock('node:fs', async () => {
const actual = await vi.importActual<typeof import('node:fs')>('node:fs');
const mocked = {
...actual,
symlinkSync: (...args: unknown[]) => symlinkSyncMock(...args),
};
return { ...mocked, default: mocked };
});
describe('fs-link', () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform');
beforeEach(() => {
symlinkSyncMock.mockReset();
});
afterEach(() => {
if (originalPlatform) {
Object.defineProperty(process, 'platform', originalPlatform);
}
});
function setPlatform(p: NodeJS.Platform): void {
Object.defineProperty(process, 'platform', { value: p, configurable: true });
}
describe('linkDirSafe', () => {
it('creates a plain dir symlink on POSIX and passes paths through unchanged', async () => {
setPlatform('darwin');
vi.resetModules();
const { linkDirSafe } = await import('@electron/gateway/fs-link');
linkDirSafe('/src/a', '/dest/a');
expect(symlinkSyncMock).toHaveBeenCalledTimes(1);
expect(symlinkSyncMock).toHaveBeenCalledWith('/src/a', '/dest/a', 'dir');
});
it('prefers junction on Windows and normalizes paths with the extended prefix', async () => {
setPlatform('win32');
vi.resetModules();
const { linkDirSafe } = await import('@electron/gateway/fs-link');
linkDirSafe('C:/foo/bar', 'C:/baz/qux');
expect(symlinkSyncMock).toHaveBeenCalledTimes(1);
expect(symlinkSyncMock).toHaveBeenCalledWith(
'\\\\?\\C:\\foo\\bar',
'\\\\?\\C:\\baz\\qux',
'junction',
);
});
it('falls back to symlink when junction creation throws on Windows', async () => {
setPlatform('win32');
vi.resetModules();
const { linkDirSafe } = await import('@electron/gateway/fs-link');
symlinkSyncMock.mockImplementationOnce(() => {
throw new Error('EXDEV: cross-volume junction not supported');
});
linkDirSafe('C:/foo', 'D:/bar');
expect(symlinkSyncMock).toHaveBeenCalledTimes(2);
const [firstCall, secondCall] = symlinkSyncMock.mock.calls;
expect(firstCall[2]).toBe('junction');
expect(secondCall[2]).toBe('dir');
});
it('rethrows when both junction and symlink fail on Windows', async () => {
setPlatform('win32');
vi.resetModules();
const { linkDirSafe } = await import('@electron/gateway/fs-link');
symlinkSyncMock.mockImplementation(() => {
throw new Error('EPERM');
});
expect(() => linkDirSafe('C:/foo', 'C:/bar')).toThrow('EPERM');
expect(symlinkSyncMock).toHaveBeenCalledTimes(2);
});
});
describe('normalizeFsPath', () => {
it('passes POSIX paths through unchanged', async () => {
setPlatform('linux');
vi.resetModules();
const { normalizeFsPath } = await import('@electron/gateway/fs-link');
expect(normalizeFsPath('/a/b/c')).toBe('/a/b/c');
expect(normalizeFsPath('')).toBe('');
});
it('adds the \\\\?\\ prefix on Windows for absolute drive paths', async () => {
setPlatform('win32');
vi.resetModules();
const { normalizeFsPath } = await import('@electron/gateway/fs-link');
expect(normalizeFsPath('C:/a/b')).toBe('\\\\?\\C:\\a\\b');
});
it('adds the UNC extended prefix for UNC paths on Windows', async () => {
setPlatform('win32');
vi.resetModules();
const { normalizeFsPath } = await import('@electron/gateway/fs-link');
expect(normalizeFsPath('//server/share/file')).toBe('\\\\?\\UNC\\server\\share\\file');
});
it('does not double-prefix already-normalized Windows paths', async () => {
setPlatform('win32');
vi.resetModules();
const { normalizeFsPath } = await import('@electron/gateway/fs-link');
const already = '\\\\?\\C:\\x\\y';
expect(normalizeFsPath(already)).toBe(already);
});
it('leaves relative Windows paths un-prefixed', async () => {
setPlatform('win32');
vi.resetModules();
const { normalizeFsPath } = await import('@electron/gateway/fs-link');
expect(normalizeFsPath('a/b')).toBe('a\\b');
});
});
});