From 8c62435a9afbe437f661dc2ab31783f5c1d73fa2 Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Tue, 3 Mar 2026 14:09:45 +0800 Subject: [PATCH 1/2] fix: handle symlinks during extension installation When installing extensions from GitHub repos containing symlinks (e.g. symlinks pointing to directories), the copy operation fails with ENOTSUP: operation not supported on socket, copyfile. Root cause: git clone without core.symlinks=true creates text files instead of real symlinks on some platforms, and fs.promises.cp cannot copy these entries properly. Fix applied at two layers: 1. git clone: add -c core.symlinks=true to preserve symlinks 2. copyExtension: add dereference:true to follow symlinks and copy actual content, with a filter to skip non-regular files 3. gemini-converter copyDirectory: resolve symlinks via realpathSync and copy target content 4. claude-converter collectResources: skip non-regular files Fixes #2050 --- .../core/src/extension/claude-converter.ts | 30 +++++++++++++------ .../core/src/extension/extensionManager.ts | 16 +++++++++- .../core/src/extension/gemini-converter.ts | 17 ++++++++++- packages/core/src/extension/github.ts | 7 ++++- 4 files changed, 58 insertions(+), 12 deletions(-) diff --git a/packages/core/src/extension/claude-converter.ts b/packages/core/src/extension/claude-converter.ts index 68da9cfff1..0829c16359 100644 --- a/packages/core/src/extension/claude-converter.ts +++ b/packages/core/src/extension/claude-converter.ts @@ -387,15 +387,15 @@ export async function convertClaudePluginPackage( const strict = marketplacePlugin.strict ?? false; let mergedConfig: ClaudePluginConfig; - if (strict) { - const pluginJsonPath = path.join( - pluginSource, - '.claude-plugin', - 'plugin.json', - ); - if (!fs.existsSync(pluginJsonPath)) { - throw new Error(`Strict mode requires plugin.json at ${pluginJsonPath}`); - } + const pluginJsonPath = path.join( + pluginSource, + '.claude-plugin', + 'plugin.json', + ); + if (strict && !fs.existsSync(pluginJsonPath)) { + throw new Error(`Strict mode requires plugin.json at ${pluginJsonPath}`); + } + if (fs.existsSync(pluginJsonPath)) { const pluginContent = fs.readFileSync(pluginJsonPath, 'utf-8'); const pluginConfig: ClaudePluginConfig = JSON.parse(pluginContent); mergedConfig = mergeClaudeConfigs(marketplacePlugin, pluginConfig); @@ -552,6 +552,18 @@ async function collectResources( const srcFile = path.join(resolvedPath, file); const destFile = path.join(finalDestDir, file); + // Check if the source is a regular file (skip sockets, FIFOs, directories behind symlinks, etc.) + try { + const fileStat = fs.statSync(srcFile); + if (!fileStat.isFile()) { + debugLogger.debug(`Skipping non-regular file: ${srcFile}`); + continue; + } + } catch { + debugLogger.debug(`Failed to stat file, skipping: ${srcFile}`); + continue; + } + // Ensure parent directory exists const destFileDir = path.dirname(destFile); if (!fs.existsSync(destFileDir)) { diff --git a/packages/core/src/extension/extensionManager.ts b/packages/core/src/extension/extensionManager.ts index 2da26995ad..629de747a4 100644 --- a/packages/core/src/extension/extensionManager.ts +++ b/packages/core/src/extension/extensionManager.ts @@ -1238,7 +1238,21 @@ export async function copyExtension( source: string, destination: string, ): Promise { - await fs.promises.cp(source, destination, { recursive: true }); + await fs.promises.cp(source, destination, { + recursive: true, + dereference: true, + filter: async (src: string) => { + try { + const stats = await fs.promises.stat(src); + // Only copy regular files and directories + // Skip sockets, FIFOs, block devices, and character devices + return stats.isFile() || stats.isDirectory(); + } catch { + // If we can't stat the file, skip it + return false; + } + }, + }); } export function getExtensionId( diff --git a/packages/core/src/extension/gemini-converter.ts b/packages/core/src/extension/gemini-converter.ts index 7f5c2d0546..b5461369e3 100644 --- a/packages/core/src/extension/gemini-converter.ts +++ b/packages/core/src/extension/gemini-converter.ts @@ -130,9 +130,24 @@ export async function copyDirectory( if (entry.isDirectory()) { await copyDirectory(sourcePath, destPath); - } else { + } else if (entry.isSymbolicLink()) { + // Resolve symlink and copy the target content + try { + const realPath = fs.realpathSync(sourcePath); + const targetStat = fs.statSync(realPath); + if (targetStat.isDirectory()) { + await copyDirectory(realPath, destPath); + } else if (targetStat.isFile()) { + fs.copyFileSync(realPath, destPath); + } + // Skip sockets, FIFOs, etc. + } catch { + // Skip broken symlinks + } + } else if (entry.isFile()) { fs.copyFileSync(sourcePath, destPath); } + // Skip sockets, FIFOs, block devices, and character devices } } diff --git a/packages/core/src/extension/github.ts b/packages/core/src/extension/github.ts index 9e1d46ed42..5ef49d35b3 100644 --- a/packages/core/src/extension/github.ts +++ b/packages/core/src/extension/github.ts @@ -75,7 +75,12 @@ export async function cloneFromGit( // We let git handle the source as is. } } - await git.clone(sourceUrl, './', ['--depth', '1']); + await git.clone(sourceUrl, './', [ + '-c', + 'core.symlinks=true', + '--depth', + '1', + ]); const remotes = await git.getRemotes(true); if (remotes.length === 0) { From b7ba1b933678bed88d177a4a97d8fc1253e2e71d Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Tue, 3 Mar 2026 14:32:22 +0800 Subject: [PATCH 2/2] fix ci test --- packages/core/src/extension/github.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/core/src/extension/github.test.ts b/packages/core/src/extension/github.test.ts index e98e6498a8..8c31b1284d 100644 --- a/packages/core/src/extension/github.test.ts +++ b/packages/core/src/extension/github.test.ts @@ -69,6 +69,8 @@ describe('git extension helpers', () => { await cloneFromGit(installMetadata, destination); expect(mockGit.clone).toHaveBeenCalledWith('http://my-repo.com', './', [ + '-c', + 'core.symlinks=true', '--depth', '1', ]);