diff --git a/src/plugins/installed-plugin-index.test.ts b/src/plugins/installed-plugin-index.test.ts index 6c30e914325..cc5bbf11c72 100644 --- a/src/plugins/installed-plugin-index.test.ts +++ b/src/plugins/installed-plugin-index.test.ts @@ -64,6 +64,7 @@ function createPluginCandidate(params: { origin?: PluginCandidate["origin"]; packageName?: string; packageVersion?: string; + packageDir?: string; packageManifest?: OpenClawPackageManifest; }): PluginCandidate { return { @@ -73,7 +74,7 @@ function createPluginCandidate(params: { origin: params.origin ?? "global", packageName: params.packageName, packageVersion: params.packageVersion, - packageDir: params.rootDir, + packageDir: params.packageDir ?? params.rootDir, packageManifest: params.packageManifest, }; } @@ -211,6 +212,33 @@ describe("installed plugin index", () => { expect(contributions.contracts.get("tools")).toEqual(["demo"]); }); + it("keeps packageJson paths root-relative when packageDir is reached through a symlink", () => { + const fixture = createRichPluginFixture(); + const linkParent = makeTempDir(); + const linkRoot = path.join(linkParent, "linked-demo"); + try { + fs.symlinkSync(fixture.rootDir, linkRoot, "dir"); + } catch { + return; + } + + const index = loadInstalledPluginIndex({ + candidates: [ + createPluginCandidate({ + rootDir: fs.realpathSync(fixture.rootDir), + packageDir: linkRoot, + packageName: "@vendor/demo-plugin", + packageVersion: "1.2.3", + }), + ], + env: hermeticEnv(), + }); + + expect(index.plugins[0]?.packageJson).toMatchObject({ + path: "package.json", + }); + }); + it("exposes cold registry records and owners for existing plugins without install ledgers", () => { const fixture = createRichPluginFixture(); const index = loadInstalledPluginIndex({ diff --git a/src/plugins/installed-plugin-index.ts b/src/plugins/installed-plugin-index.ts index f437d538cc4..03ab686ff3b 100644 --- a/src/plugins/installed-plugin-index.ts +++ b/src/plugins/installed-plugin-index.ts @@ -19,6 +19,7 @@ import { type PluginManifestRegistry, } from "./manifest-registry.js"; import type { PluginDiagnostic } from "./manifest-types.js"; +import { safeRealpathSync } from "./path-safety.js"; import { hasKind } from "./slots.js"; export const INSTALLED_PLUGIN_INDEX_VERSION = 1; @@ -284,10 +285,17 @@ function resolvePackageJsonPath(candidate: PluginCandidate | undefined): string if (!candidate?.packageDir) { return undefined; } - const packageJsonPath = path.join(candidate.packageDir, "package.json"); + const packageDir = safeRealpathSync(candidate.packageDir) ?? path.resolve(candidate.packageDir); + const packageJsonPath = path.join(packageDir, "package.json"); return fs.existsSync(packageJsonPath) ? packageJsonPath : undefined; } +function resolvePackageJsonRelativePath(rootDir: string, packageJsonPath: string): string { + const resolvedRootDir = safeRealpathSync(rootDir) ?? path.resolve(rootDir); + const relativePath = path.relative(resolvedRootDir, packageJsonPath) || "package.json"; + return relativePath.split(path.sep).join("/"); +} + function resolvePackageJsonRecord(params: { candidate: PluginCandidate | undefined; packageJsonPath: string | undefined; @@ -307,7 +315,7 @@ function resolvePackageJsonRecord(params: { return undefined; } return { - path: path.relative(params.candidate.rootDir, params.packageJsonPath) || "package.json", + path: resolvePackageJsonRelativePath(params.candidate.rootDir, params.packageJsonPath), hash, }; }