fix: keep local marketplace paths stable (#60556) (thanks @eleqtrizit)

This commit is contained in:
Peter Steinberger
2026-04-04 11:18:29 +09:00
parent e8ebd6ab8c
commit 94b0062e90
3 changed files with 58 additions and 93 deletions

View File

@@ -115,6 +115,7 @@ Docs: https://docs.openclaw.ai
- Matrix/backup reset: recreate secret storage during backup reset when stale SSSS state blocks durable backup-key reload, including no-backup repair paths. (#60599) thanks @emonty.
- Plugins/media understanding: enable bundled Groq and Deepgram providers by default so configured audio transcription models load without extra plugin activation config. (#59982) Thanks @yxjsxy.
- Providers/OpenAI Codex: add forward-compat `openai-codex/gpt-5.4-mini` synthesis across provider runtime, model catalog, and model listing so Codex mini works before bundled Pi catalog updates land.
- Plugins/marketplace: block remote marketplace symlink escapes without rewriting ordinary local marketplace install paths. (#60556) Thanks @eleqtrizit.
## 2026.4.2

View File

@@ -283,91 +283,50 @@ describe("marketplace plugins", () => {
});
});
it.runIf(process.platform !== "win32")(
"preserves relative local marketplace installs when the marketplace root is symlinked",
async () => {
await withTempDir(async (parentDir) => {
const realRootDir = path.join(parentDir, "real-marketplace");
const symlinkRootDir = path.join(parentDir, "marketplace-link");
const pluginDir = path.join(realRootDir, "plugins", "frontend-design");
await fs.mkdir(realRootDir, { recursive: true });
await writeLocalMarketplaceFixture({
rootDir: realRootDir,
pluginDir,
manifest: {
plugins: [
{
name: "frontend-design",
source: "./plugins/frontend-design",
},
],
},
});
await fs.symlink(realRootDir, symlinkRootDir);
installPluginFromPathMock.mockResolvedValue({
ok: true,
pluginId: "frontend-design",
targetDir: "/tmp/frontend-design",
version: "0.1.0",
extensions: ["index.ts"],
});
const result = await installPluginFromMarketplace({
marketplace: symlinkRootDir,
plugin: "frontend-design",
});
expectLocalMarketplaceInstallResult({
result,
pluginDir,
marketplaceSource: symlinkRootDir,
});
it("preserves the logical local install path instead of canonicalizing it", async () => {
await withTempDir(async (rootDir) => {
const canonicalRootDir = await fs.realpath(rootDir);
const pluginDir = path.join(rootDir, "plugins", "frontend-design");
const canonicalPluginDir = path.join(canonicalRootDir, "plugins", "frontend-design");
const manifestPath = await writeLocalMarketplaceFixture({
rootDir,
pluginDir,
manifest: {
plugins: [
{
name: "frontend-design",
source: "./plugins/frontend-design",
},
],
},
});
},
);
it.runIf(process.platform !== "win32")(
"preserves relative local marketplace installs when the plugin path goes through a symlink",
async () => {
await withTempDir(async (rootDir) => {
const sharedPluginsDir = path.join(rootDir, "..", "shared-plugins");
const pluginDir = path.join(sharedPluginsDir, "frontend-design");
const linkedPluginDir = path.join(rootDir, "plugins", "frontend-design");
await fs.mkdir(pluginDir, { recursive: true });
await fs.mkdir(path.dirname(linkedPluginDir), { recursive: true });
await fs.symlink(pluginDir, linkedPluginDir);
const manifestPath = await writeLocalMarketplaceFixture({
rootDir,
manifest: {
plugins: [
{
name: "frontend-design",
source: "./plugins/frontend-design",
},
],
},
});
installPluginFromPathMock.mockResolvedValue({
ok: true,
pluginId: "frontend-design",
targetDir: "/tmp/frontend-design",
version: "0.1.0",
extensions: ["index.ts"],
});
const result = await installPluginFromMarketplace({
marketplace: manifestPath,
plugin: "frontend-design",
});
expectLocalMarketplaceInstallResult({
result,
pluginDir: linkedPluginDir,
marketplaceSource: manifestPath,
});
installPluginFromPathMock.mockResolvedValue({
ok: true,
pluginId: "frontend-design",
targetDir: "/tmp/frontend-design",
version: "0.1.0",
extensions: ["index.ts"],
});
},
);
const result = await installPluginFromMarketplace({
marketplace: manifestPath,
plugin: "frontend-design",
});
expectLocalMarketplaceInstallResult({
result,
pluginDir,
marketplaceSource: manifestPath,
});
if (canonicalPluginDir !== pluginDir) {
expect(installPluginFromPathMock).not.toHaveBeenCalledWith(
expect.objectContaining({
path: canonicalPluginDir,
}),
);
}
});
});
it("passes dangerous force unsafe install through to marketplace path installs", async () => {
await withTempDir(async (rootDir) => {

View File

@@ -367,7 +367,7 @@ async function resolveLocalMarketplaceSource(
const rootDir = deriveMarketplaceRootFromManifestPath(resolved);
return {
ok: true,
rootDir: await fs.realpath(rootDir),
rootDir,
manifestPath: resolved,
};
}
@@ -377,11 +377,10 @@ async function resolveLocalMarketplaceSource(
}
const rootDir = path.basename(resolved) === ".claude-plugin" ? path.dirname(resolved) : resolved;
const canonicalRootDir = await fs.realpath(rootDir);
for (const candidate of MARKETPLACE_MANIFEST_CANDIDATES) {
const manifestPath = path.join(rootDir, candidate);
if (await pathExists(manifestPath)) {
return { ok: true, rootDir: canonicalRootDir, manifestPath };
return { ok: true, rootDir, manifestPath };
}
}
@@ -811,7 +810,7 @@ async function downloadUrlToTempFile(
async function ensureInsideMarketplaceRoot(
rootDir: string,
candidate: string,
options?: { enforceCanonicalContainment?: boolean },
options?: { canonicalRootDir?: string },
): Promise<{ ok: true; path: string } | { ok: false; error: string }> {
const resolved = path.resolve(rootDir, candidate);
const resolvedExists = await pathExists(resolved);
@@ -823,14 +822,14 @@ async function ensureInsideMarketplaceRoot(
};
}
if (options?.enforceCanonicalContainment === true) {
if (options?.canonicalRootDir) {
try {
const rootLstat = await fs.lstat(rootDir);
const rootLstat = await fs.lstat(options.canonicalRootDir);
if (!rootLstat.isDirectory()) {
throw new Error("invalid marketplace root");
}
const rootRealPath = await fs.realpath(rootDir);
const rootRealPath = await fs.realpath(options.canonicalRootDir);
let existingPath = resolved;
// `pathExists` uses `fs.access`, so dangling symlinks are treated as missing and we walk up
// to the nearest existing ancestor. Live symlinks stop here and are canonicalized below.
@@ -882,6 +881,7 @@ async function validateMarketplaceManifest(params: {
return { ok: true, manifest: params.manifest };
}
const canonicalRootDir = await fs.realpath(params.rootDir);
for (const plugin of params.manifest.plugins) {
const source = plugin.source;
if (source.kind === "path") {
@@ -902,7 +902,7 @@ async function validateMarketplaceManifest(params: {
};
}
const resolved = await ensureInsideMarketplaceRoot(params.rootDir, source.path, {
enforceCanonicalContainment: true,
canonicalRootDir,
});
if (!resolved.ok) {
return {
@@ -951,10 +951,14 @@ async function resolveMarketplaceEntryInstallPath(params: {
error: `unsupported remote plugin path source: ${params.source.path}`,
};
}
const canonicalRootDir =
params.marketplaceOrigin === "remote"
? await fs.realpath(params.marketplaceRootDir)
: undefined;
const resolved = path.isAbsolute(params.source.path)
? { ok: true as const, path: params.source.path }
: await ensureInsideMarketplaceRoot(params.marketplaceRootDir, params.source.path, {
enforceCanonicalContainment: params.marketplaceOrigin === "remote",
canonicalRootDir,
});
if (!resolved.ok) {
return resolved;
@@ -983,8 +987,9 @@ async function resolveMarketplaceEntryInstallPath(params: {
params.source.kind === "github" || params.source.kind === "git"
? params.source.path?.trim() || "."
: params.source.path.trim();
const canonicalRootDir = await fs.realpath(cloned.rootDir);
const target = await ensureInsideMarketplaceRoot(cloned.rootDir, subPath, {
enforceCanonicalContainment: true,
canonicalRootDir,
});
if (!target.ok) {
await cloned.cleanup();