From 28f264034b3928357a9032cb6b4c4dadc0b51a00 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 27 Apr 2026 14:17:24 +0100 Subject: [PATCH] fix: discover symlinked plugin directories --- CHANGELOG.md | 1 + src/plugins/discovery.test.ts | 80 ++++++++++++++++++++++++++++++++--- src/plugins/discovery.ts | 30 ++++++++++++- 3 files changed, 102 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b422e773df7..c801cc13989 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Google Meet: route local Chrome joins through OpenClaw browser control instead of raw default Chrome, so agents use the configured OpenClaw browser profile when opening Meet. Thanks @openclaw. +- Plugins/discovery: follow symlinked plugin directories in global and workspace plugin roots while keeping broken links ignored and existing package safety checks in place. Fixes #36754; carries forward #72695 and #63206. Thanks @Quackstro, @ming1523, and @xsfX20. - Plugins/startup: reuse canonical realpath lookups throughout each plugin discovery pass, including package and manifest boundary checks, so Windows npm-global startups no longer repeat expensive path resolution for the same plugin roots. Fixes #65733. Thanks @welfo-beo. - Reply/link understanding: keep media and link preprocessing on stable runtime entrypoints and continue with raw message content if optional enrichment fails, so URL-bearing messages are no longer dropped after stale runtime chunk upgrades. Fixes #68466. Thanks @songshikang0111. - Discord: persist routed model-picker overrides when the hidden `/model` dispatch succeeds but the bound thread session store is still stale, including LM Studio suffixed model ids. Carries forward #61473. Thanks @Nanako0129. diff --git a/src/plugins/discovery.test.ts b/src/plugins/discovery.test.ts index 38929dd3f99..2d919fd8e50 100644 --- a/src/plugins/discovery.test.ts +++ b/src/plugins/discovery.test.ts @@ -18,13 +18,17 @@ function makeTempDir() { const mkdirSafe = mkdirSafeDir; +function symlinkDirectory(target: string, linkPath: string): void { + fs.symlinkSync(target, linkPath, process.platform === "win32" ? "junction" : "dir"); +} + const canCreateDirectorySymlinks = (() => { const probeDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-symlink-probe-")); const targetDir = path.join(probeDir, "target"); const linkDir = path.join(probeDir, "link"); try { fs.mkdirSync(targetDir); - fs.symlinkSync(targetDir, linkDir, process.platform === "win32" ? "junction" : "dir"); + symlinkDirectory(targetDir, linkDir); return true; } catch { return false; @@ -318,6 +322,72 @@ describe("discoverOpenClawPlugins", () => { expectCandidateIds(candidates, { includes: ["alpha", "beta"] }); }); + it.skipIf(!canCreateDirectorySymlinks)( + "discovers symlinked plugin directories in global roots", + async () => { + const stateDir = makeTempDir(); + const globalExt = path.join(stateDir, "extensions"); + mkdirSafe(globalExt); + + const linkedPluginDir = path.join(stateDir, "linked-plugin-src"); + createPackagePluginWithEntry({ + packageDir: linkedPluginDir, + packageName: "@openclaw/linked-plugin", + pluginId: "linked-plugin", + }); + + symlinkDirectory(linkedPluginDir, path.join(globalExt, "linked-plugin")); + + const { candidates, diagnostics } = await discoverWithStateDir(stateDir, {}); + expectCandidateIds(candidates, { includes: ["linked-plugin"] }); + expect(findCandidateById(candidates, "linked-plugin")?.rootDir).toBe( + fs.realpathSync(linkedPluginDir), + ); + expect(diagnostics).toEqual([]); + }, + ); + + it.skipIf(!canCreateDirectorySymlinks)( + "discovers symlinked plugin directories in workspace roots", + async () => { + const stateDir = makeTempDir(); + const workspaceDir = path.join(stateDir, "workspace"); + const workspaceExt = path.join(workspaceDir, ".openclaw", "extensions"); + mkdirSafe(workspaceExt); + + const linkedPluginDir = path.join(stateDir, "workspace-linked-plugin-src"); + createPackagePluginWithEntry({ + packageDir: linkedPluginDir, + packageName: "@openclaw/workspace-linked-plugin", + pluginId: "workspace-linked-plugin", + }); + + symlinkDirectory(linkedPluginDir, path.join(workspaceExt, "workspace-linked-plugin")); + + const { candidates, diagnostics } = await discoverWithStateDir(stateDir, { workspaceDir }); + expectCandidateIds(candidates, { includes: ["workspace-linked-plugin"] }); + expect(findCandidateById(candidates, "workspace-linked-plugin")?.rootDir).toBe( + fs.realpathSync(linkedPluginDir), + ); + expect(diagnostics).toEqual([]); + }, + ); + + it.skipIf(process.platform === "win32" || !canCreateDirectorySymlinks)( + "ignores broken symlinked plugin directories in scanned roots", + async () => { + const stateDir = makeTempDir(); + const globalExt = path.join(stateDir, "extensions"); + mkdirSafe(globalExt); + + symlinkDirectory(path.join(stateDir, "missing-plugin-src"), path.join(globalExt, "missing")); + + const { candidates, diagnostics } = await discoverWithStateDir(stateDir, {}); + expectCandidateIds(candidates, { excludes: ["missing"] }); + expect(diagnostics).toEqual([]); + }, + ); + it("does not recurse arbitrary workspace directories for plugin auto-discovery", () => { const stateDir = makeTempDir(); const workspaceDir = path.join(stateDir, "workspace"); @@ -631,11 +701,7 @@ describe("discoverOpenClawPlugins", () => { writePluginEntry(path.join(realPackageDir, "src", "index.ts")); const linkedPackageDir = path.join(stateDir, "linked-pack"); - fs.symlinkSync( - realPackageDir, - linkedPackageDir, - process.platform === "win32" ? "junction" : "dir", - ); + symlinkDirectory(realPackageDir, linkedPackageDir); const canonicalPackageDir = fs.realpathSync(realPackageDir); const realpathSync = vi.spyOn(fs, "realpathSync"); @@ -1100,7 +1166,7 @@ describe("discoverOpenClawPlugins", () => { mkdirSafe(outsideDir); fs.writeFileSync(path.join(outsideDir, "escape.ts"), "export default {}", "utf-8"); try { - fs.symlinkSync(outsideDir, linkedDir, process.platform === "win32" ? "junction" : "dir"); + symlinkDirectory(outsideDir, linkedDir); } catch { return false; } diff --git a/src/plugins/discovery.ts b/src/plugins/discovery.ts index 96fe87a44e8..ec65fc6d279 100644 --- a/src/plugins/discovery.ts +++ b/src/plugins/discovery.ts @@ -353,6 +353,30 @@ function shouldIgnoreScannedDirectory(dirName: string): boolean { return false; } +function resolveScannedEntryType(entry: fs.Dirent, fullPath: string): "file" | "directory" | null { + if (entry.isFile()) { + return "file"; + } + if (entry.isDirectory()) { + return "directory"; + } + if (!entry.isSymbolicLink()) { + return null; + } + + const stat = safeStatSync(fullPath); + if (!stat) { + return null; + } + if (stat.isFile()) { + return "file"; + } + if (stat.isDirectory()) { + return "directory"; + } + return null; +} + function resolvesToSameDirectory( left: string | undefined, right: string | undefined, @@ -621,7 +645,8 @@ function discoverInDirectory(params: { for (const entry of entries) { const fullPath = path.join(params.dir, entry.name); - if (entry.isFile()) { + const entryType = resolveScannedEntryType(entry, fullPath); + if (entryType === "file") { if (!isExtensionFile(fullPath)) { continue; } @@ -637,8 +662,9 @@ function discoverInDirectory(params: { workspaceDir: params.workspaceDir, realpathCache: params.realpathCache, }); + continue; } - if (!entry.isDirectory()) { + if (entryType !== "directory") { continue; } if (params.skipDirectories?.has(entry.name)) {