fix(plugins): allow hardlinks for bundled plugins (fixes #28175, #28404) (openclaw#32119) thanks @markfietje

Verified:
- pnpm install --frozen-lockfile
- pnpm build
- pnpm check
- pnpm test:macmini

Co-authored-by: markfietje <4325889+markfietje@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
This commit is contained in:
markfietje
2026-03-02 22:10:31 +00:00
committed by GitHub
parent 11dcf96628
commit 49687d313c
9 changed files with 122 additions and 11 deletions

View File

@@ -21,7 +21,7 @@ export function resolveBundledPluginSources(params: {
if (candidate.origin !== "bundled") {
continue;
}
const manifest = loadPluginManifest(candidate.rootDir);
const manifest = loadPluginManifest(candidate.rootDir, false);
if (!manifest.ok) {
continue;
}

View File

@@ -225,12 +225,13 @@ function shouldIgnoreScannedDirectory(dirName: string): boolean {
return false;
}
function readPackageManifest(dir: string): PackageManifest | null {
function readPackageManifest(dir: string, rejectHardlinks = true): PackageManifest | null {
const manifestPath = path.join(dir, "package.json");
const opened = openBoundaryFileSync({
absolutePath: manifestPath,
rootPath: dir,
boundaryLabel: "plugin package directory",
rejectHardlinks,
});
if (!opened.ok) {
return null;
@@ -318,12 +319,14 @@ function resolvePackageEntrySource(params: {
entryPath: string;
sourceLabel: string;
diagnostics: PluginDiagnostic[];
rejectHardlinks?: boolean;
}): string | null {
const source = path.resolve(params.packageDir, params.entryPath);
const opened = openBoundaryFileSync({
absolutePath: source,
rootPath: params.packageDir,
boundaryLabel: "plugin package directory",
rejectHardlinks: params.rejectHardlinks ?? true,
});
if (!opened.ok) {
params.diagnostics.push({
@@ -387,7 +390,8 @@ function discoverInDirectory(params: {
continue;
}
const manifest = readPackageManifest(fullPath);
const rejectHardlinks = params.origin !== "bundled";
const manifest = readPackageManifest(fullPath, rejectHardlinks);
const extensionResolution = resolvePackageExtensionEntries(manifest ?? undefined);
const extensions = extensionResolution.status === "ok" ? extensionResolution.entries : [];
@@ -398,6 +402,7 @@ function discoverInDirectory(params: {
entryPath: extPath,
sourceLabel: fullPath,
diagnostics: params.diagnostics,
rejectHardlinks,
});
if (!resolved) {
continue;
@@ -488,7 +493,8 @@ function discoverFromPath(params: {
}
if (stat.isDirectory()) {
const manifest = readPackageManifest(resolved);
const rejectHardlinks = params.origin !== "bundled";
const manifest = readPackageManifest(resolved, rejectHardlinks);
const extensionResolution = resolvePackageExtensionEntries(manifest ?? undefined);
const extensions = extensionResolution.status === "ok" ? extensionResolution.entries : [];
@@ -499,6 +505,7 @@ function discoverFromPath(params: {
entryPath: extPath,
sourceLabel: resolved,
diagnostics: params.diagnostics,
rejectHardlinks,
});
if (!source) {
continue;

View File

@@ -922,6 +922,58 @@ describe("loadOpenClawPlugins", () => {
expect(registry.diagnostics.some((entry) => entry.message.includes("escapes"))).toBe(true);
});
it("allows bundled plugin entry files that are hardlinked aliases", () => {
if (process.platform === "win32") {
return;
}
const bundledDir = makeTempDir();
const pluginDir = path.join(bundledDir, "hardlinked-bundled");
fs.mkdirSync(pluginDir, { recursive: true });
const outsideDir = makeTempDir();
const outsideEntry = path.join(outsideDir, "outside.cjs");
fs.writeFileSync(
outsideEntry,
'module.exports = { id: "hardlinked-bundled", register() {} };',
"utf-8",
);
const plugin = writePlugin({
id: "hardlinked-bundled",
body: 'module.exports = { id: "hardlinked-bundled", register() {} };',
dir: pluginDir,
filename: "index.cjs",
});
fs.rmSync(plugin.file);
try {
fs.linkSync(outsideEntry, plugin.file);
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "EXDEV") {
return;
}
throw err;
}
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledDir;
const registry = loadOpenClawPlugins({
cache: false,
workspaceDir: bundledDir,
config: {
plugins: {
entries: {
"hardlinked-bundled": { enabled: true },
},
allow: ["hardlinked-bundled"],
},
},
});
const record = registry.plugins.find((entry) => entry.id === "hardlinked-bundled");
expect(record?.status).toBe("loaded");
expect(registry.diagnostics.some((entry) => entry.message.includes("unsafe plugin path"))).toBe(
false,
);
});
it("prefers dist plugin-sdk alias when loader runs from dist", () => {
const { root, distFile } = createPluginSdkAliasFixture();

View File

@@ -538,9 +538,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
absolutePath: candidate.source,
rootPath: pluginRoot,
boundaryLabel: "plugin root",
// Discovery stores rootDir as realpath but source may still be a lexical alias
// (e.g. /var/... vs /private/var/... on macOS). Canonical boundary checks
// still enforce containment; skip lexical pre-check to avoid false escapes.
rejectHardlinks: candidate.origin !== "bundled",
skipLexicalRootCheck: true,
});
if (!opened.ok) {

View File

@@ -233,4 +233,40 @@ describe("loadPluginManifestRegistry", () => {
registry.diagnostics.some((diag) => diag.message.includes("unsafe plugin manifest path")),
).toBe(true);
});
it("allows bundled manifest paths that are hardlinked aliases", () => {
if (process.platform === "win32") {
return;
}
const rootDir = makeTempDir();
const outsideDir = makeTempDir();
const outsideManifest = path.join(outsideDir, "openclaw.plugin.json");
const linkedManifest = path.join(rootDir, "openclaw.plugin.json");
fs.writeFileSync(path.join(rootDir, "index.ts"), "export default function () {}", "utf-8");
fs.writeFileSync(
outsideManifest,
JSON.stringify({ id: "bundled-hardlink", configSchema: { type: "object" } }),
"utf-8",
);
try {
fs.linkSync(outsideManifest, linkedManifest);
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "EXDEV") {
return;
}
throw err;
}
const registry = loadRegistry([
createPluginCandidate({
idHint: "bundled-hardlink",
rootDir,
origin: "bundled",
}),
]);
expect(registry.plugins.some((entry) => entry.id === "bundled-hardlink")).toBe(true);
expect(
registry.diagnostics.some((diag) => diag.message.includes("unsafe plugin manifest path")),
).toBe(false);
});
});

View File

@@ -167,7 +167,8 @@ export function loadPluginManifestRegistry(params: {
const realpathCache = new Map<string, string>();
for (const candidate of candidates) {
const manifestRes = loadPluginManifest(candidate.rootDir);
const rejectHardlinks = candidate.origin !== "bundled";
const manifestRes = loadPluginManifest(candidate.rootDir, rejectHardlinks);
if (!manifestRes.ok) {
diagnostics.push({
level: "error",

View File

@@ -42,12 +42,16 @@ export function resolvePluginManifestPath(rootDir: string): string {
return path.join(rootDir, PLUGIN_MANIFEST_FILENAME);
}
export function loadPluginManifest(rootDir: string): PluginManifestLoadResult {
export function loadPluginManifest(
rootDir: string,
rejectHardlinks = true,
): PluginManifestLoadResult {
const manifestPath = resolvePluginManifestPath(rootDir);
const opened = openBoundaryFileSync({
absolutePath: manifestPath,
rootPath: rootDir,
boundaryLabel: "plugin root",
rejectHardlinks,
});
if (!opened.ok) {
if (opened.reason === "path") {