From efa76b37d3be728b9cc7801032679d03553a098b Mon Sep 17 00:00:00 2001 From: Haze <709547807@qq.com> Date: Fri, 24 Apr 2026 17:11:52 +0800 Subject: [PATCH] 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 --- electron/gateway/config-sync.ts | 19 +---- electron/gateway/fs-link.ts | 47 +++++++++++ tests/unit/fs-link.test.ts | 141 ++++++++++++++++++++++++++++++++ 3 files changed, 192 insertions(+), 15 deletions(-) create mode 100644 electron/gateway/fs-link.ts create mode 100644 tests/unit/fs-link.test.ts diff --git a/electron/gateway/config-sync.ts b/electron/gateway/config-sync.ts index f7b0d2c..20a075b 100644 --- a/electron/gateway/config-sync.ts +++ b/electron/gateway/config-sync.ts @@ -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 */ } } diff --git a/electron/gateway/fs-link.ts b/electron/gateway/fs-link.ts new file mode 100644 index 0000000..207f0fb --- /dev/null +++ b/electron/gateway/fs-link.ts @@ -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'); + } +} diff --git a/tests/unit/fs-link.test.ts b/tests/unit/fs-link.test.ts new file mode 100644 index 0000000..92562fe --- /dev/null +++ b/tests/unit/fs-link.test.ts @@ -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('fs'); + const mocked = { + ...actual, + symlinkSync: (...args: unknown[]) => symlinkSyncMock(...args), + }; + return { ...mocked, default: mocked }; +}); + +vi.mock('node:fs', async () => { + const actual = await vi.importActual('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'); + }); + }); +});