mirror of
https://github.com/moltbot/moltbot.git
synced 2026-04-23 14:45:46 +00:00
fix: keep local marketplace paths stable (#60556) (thanks @eleqtrizit)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user