Plugins: preserve scoped ids and reserve bundled duplicates (#47413)

* Plugins: preserve scoped ids and reserve bundled duplicates

* Changelog: add plugin scoped id note

* Plugins: harden scoped install ids

* Plugins: reserve scoped install dirs

* Plugins: migrate legacy scoped update ids
This commit is contained in:
Vincent Koc 2026-03-15 09:07:10 -07:00 committed by GitHub
parent 0c7ae04262
commit 8d44b16b7c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 377 additions and 41 deletions

View File

@ -50,6 +50,7 @@ Docs: https://docs.openclaw.ai
- ACP/approvals: use canonical tool identity for prompting decisions and fail closed when conflicting tool identity hints are present. Thanks @vincentkoc.
- Telegram/message send: forward `--force-document` through the `sendPayload` path as well as `sendMedia`, so Telegram payload sends with `channelData` keep uploading images as documents instead of silently falling back to compressed photo sends. (#47119) Thanks @thepagent.
- Telegram/message chunking: preserve spaces, paragraph separators, and word boundaries when HTML overflow rechunking splits formatted replies. (#47274)
- Plugins/scoped ids: preserve scoped plugin ids during install and config keying, and keep bundled plugins ahead of discovered duplicate ids by default so `@scope/name` plugins no longer collide with unscoped installs. Thanks @vincentkoc.
## 2026.3.13

View File

@ -47,8 +47,10 @@ export function resolveSafeInstallDir(params: {
baseDir: string;
id: string;
invalidNameMessage: string;
nameEncoder?: (id: string) => string;
}): { ok: true; path: string } | { ok: false; error: string } {
const targetDir = path.join(params.baseDir, safeDirName(params.id));
const encodedName = (params.nameEncoder ?? safeDirName)(params.id);
const targetDir = path.join(params.baseDir, encodedName);
const resolvedBase = path.resolve(params.baseDir);
const resolvedTarget = path.resolve(targetDir);
const relative = path.relative(resolvedBase, resolvedTarget);

View File

@ -7,12 +7,14 @@ export async function resolveCanonicalInstallTarget(params: {
id: string;
invalidNameMessage: string;
boundaryLabel: string;
nameEncoder?: (id: string) => string;
}): Promise<{ ok: true; targetDir: string } | { ok: false; error: string }> {
await fs.mkdir(params.baseDir, { recursive: true });
const targetDirResult = resolveSafeInstallDir({
baseDir: params.baseDir,
id: params.id,
invalidNameMessage: params.invalidNameMessage,
nameEncoder: params.nameEncoder,
});
if (!targetDirResult.ok) {
return { ok: false, error: targetDirResult.error };

View File

@ -3,6 +3,7 @@ import os from "node:os";
import path from "node:path";
import * as tar from "tar";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { safePathSegmentHashed } from "../infra/install-safe-path.js";
import * as skillScanner from "../security/skill-scanner.js";
import { expectSingleNpmPackIgnoreScriptsCall } from "../test-utils/exec-assertions.js";
import {
@ -20,6 +21,7 @@ let installPluginFromDir: typeof import("./install.js").installPluginFromDir;
let installPluginFromNpmSpec: typeof import("./install.js").installPluginFromNpmSpec;
let installPluginFromPath: typeof import("./install.js").installPluginFromPath;
let PLUGIN_INSTALL_ERROR_CODE: typeof import("./install.js").PLUGIN_INSTALL_ERROR_CODE;
let resolvePluginInstallDir: typeof import("./install.js").resolvePluginInstallDir;
let runCommandWithTimeout: typeof import("../process/exec.js").runCommandWithTimeout;
let suiteTempRoot = "";
let suiteFixtureRoot = "";
@ -157,7 +159,9 @@ async function setupVoiceCallArchiveInstall(params: { outName: string; version:
}
function expectPluginFiles(result: { targetDir: string }, stateDir: string, pluginId: string) {
expect(result.targetDir).toBe(path.join(stateDir, "extensions", pluginId));
expect(result.targetDir).toBe(
resolvePluginInstallDir(pluginId, path.join(stateDir, "extensions")),
);
expect(fs.existsSync(path.join(result.targetDir, "package.json"))).toBe(true);
expect(fs.existsSync(path.join(result.targetDir, "dist", "index.js"))).toBe(true);
}
@ -331,6 +335,7 @@ beforeAll(async () => {
installPluginFromNpmSpec,
installPluginFromPath,
PLUGIN_INSTALL_ERROR_CODE,
resolvePluginInstallDir,
} = await import("./install.js"));
({ runCommandWithTimeout } = await import("../process/exec.js"));
@ -394,7 +399,7 @@ beforeEach(() => {
});
describe("installPluginFromArchive", () => {
it("installs into ~/.openclaw/extensions and uses unscoped id", async () => {
it("installs into ~/.openclaw/extensions and preserves scoped package ids", async () => {
const { stateDir, archivePath, extensionsDir } = await setupVoiceCallArchiveInstall({
outName: "plugin.tgz",
version: "0.0.1",
@ -404,7 +409,7 @@ describe("installPluginFromArchive", () => {
archivePath,
extensionsDir,
});
expectSuccessfulArchiveInstall({ result, stateDir, pluginId: "voice-call" });
expectSuccessfulArchiveInstall({ result, stateDir, pluginId: "@openclaw/voice-call" });
});
it("rejects installing when plugin already exists", async () => {
@ -443,7 +448,7 @@ describe("installPluginFromArchive", () => {
archivePath,
extensionsDir,
});
expectSuccessfulArchiveInstall({ result, stateDir, pluginId: "zipper" });
expectSuccessfulArchiveInstall({ result, stateDir, pluginId: "@openclaw/zipper" });
});
it("allows updates when mode is update", async () => {
@ -615,16 +620,17 @@ describe("installPluginFromArchive", () => {
});
describe("installPluginFromDir", () => {
function expectInstalledAsMemoryCognee(
function expectInstalledWithPluginId(
result: Awaited<ReturnType<typeof installPluginFromDir>>,
extensionsDir: string,
pluginId: string,
) {
expect(result.ok).toBe(true);
if (!result.ok) {
return;
}
expect(result.pluginId).toBe("memory-cognee");
expect(result.targetDir).toBe(path.join(extensionsDir, "memory-cognee"));
expect(result.pluginId).toBe(pluginId);
expect(result.targetDir).toBe(resolvePluginInstallDir(pluginId, extensionsDir));
}
it("uses --ignore-scripts for dependency install", async () => {
@ -689,17 +695,17 @@ describe("installPluginFromDir", () => {
logger: { info: (msg: string) => infoMessages.push(msg), warn: () => {} },
});
expectInstalledAsMemoryCognee(res, extensionsDir);
expectInstalledWithPluginId(res, extensionsDir, "memory-cognee");
expect(
infoMessages.some((msg) =>
msg.includes(
'Plugin manifest id "memory-cognee" differs from npm package name "cognee-openclaw"',
'Plugin manifest id "memory-cognee" differs from npm package name "@openclaw/cognee-openclaw"',
),
),
).toBe(true);
});
it("normalizes scoped manifest ids to unscoped install keys", async () => {
it("preserves scoped manifest ids as install keys", async () => {
const { pluginDir, extensionsDir } = setupManifestInstallFixture({
manifestId: "@team/memory-cognee",
});
@ -707,11 +713,62 @@ describe("installPluginFromDir", () => {
const res = await installPluginFromDir({
dirPath: pluginDir,
extensionsDir,
expectedPluginId: "memory-cognee",
expectedPluginId: "@team/memory-cognee",
logger: { info: () => {}, warn: () => {} },
});
expectInstalledAsMemoryCognee(res, extensionsDir);
expectInstalledWithPluginId(res, extensionsDir, "@team/memory-cognee");
});
it("preserves scoped package names when no plugin manifest id is present", async () => {
const { pluginDir, extensionsDir } = setupInstallPluginFromDirFixture();
const res = await installPluginFromDir({
dirPath: pluginDir,
extensionsDir,
});
expectInstalledWithPluginId(res, extensionsDir, "@openclaw/test-plugin");
});
it("accepts legacy unscoped expected ids for scoped package names without manifest ids", async () => {
const { pluginDir, extensionsDir } = setupInstallPluginFromDirFixture();
const res = await installPluginFromDir({
dirPath: pluginDir,
extensionsDir,
expectedPluginId: "test-plugin",
});
expectInstalledWithPluginId(res, extensionsDir, "@openclaw/test-plugin");
});
it("rejects bare @ as an invalid scoped id", () => {
expect(() => resolvePluginInstallDir("@")).toThrow(
"invalid plugin name: scoped ids must use @scope/name format",
);
});
it("rejects empty scoped segments like @/name", () => {
expect(() => resolvePluginInstallDir("@/name")).toThrow(
"invalid plugin name: scoped ids must use @scope/name format",
);
});
it("rejects two-segment ids without a scope prefix", () => {
expect(() => resolvePluginInstallDir("team/name")).toThrow(
"invalid plugin name: scoped ids must use @scope/name format",
);
});
it("uses a unique hashed install dir for scoped ids", () => {
const extensionsDir = path.join(makeTempDir(), "extensions");
const scopedTarget = resolvePluginInstallDir("@scope/name", extensionsDir);
const hashedFlatId = safePathSegmentHashed("@scope/name");
const flatTarget = resolvePluginInstallDir(hashedFlatId, extensionsDir);
expect(path.basename(scopedTarget)).toBe(`@${hashedFlatId}`);
expect(scopedTarget).not.toBe(flatTarget);
});
});

View File

@ -11,6 +11,7 @@ import { installPackageDir } from "../infra/install-package-dir.js";
import {
resolveSafeInstallDir,
safeDirName,
safePathSegmentHashed,
unscopedPackageName,
} from "../infra/install-safe-path.js";
import {
@ -84,19 +85,68 @@ function safeFileName(input: string): string {
return safeDirName(input);
}
function encodePluginInstallDirName(pluginId: string): string {
const trimmed = pluginId.trim();
if (!trimmed.includes("/")) {
return safeDirName(trimmed);
}
// Scoped plugin ids need a reserved on-disk namespace so they cannot collide
// with valid unscoped ids that happen to match the hashed slug.
return `@${safePathSegmentHashed(trimmed)}`;
}
function validatePluginId(pluginId: string): string | null {
if (!pluginId) {
const trimmed = pluginId.trim();
if (!trimmed) {
return "invalid plugin name: missing";
}
if (pluginId === "." || pluginId === "..") {
return "invalid plugin name: reserved path segment";
}
if (pluginId.includes("/") || pluginId.includes("\\")) {
if (trimmed.includes("\\")) {
return "invalid plugin name: path separators not allowed";
}
const segments = trimmed.split("/");
if (segments.some((segment) => !segment)) {
return "invalid plugin name: malformed scope";
}
if (segments.some((segment) => segment === "." || segment === "..")) {
return "invalid plugin name: reserved path segment";
}
if (segments.length === 1) {
if (trimmed.startsWith("@")) {
return "invalid plugin name: scoped ids must use @scope/name format";
}
return null;
}
if (segments.length !== 2) {
return "invalid plugin name: path separators not allowed";
}
if (!segments[0]?.startsWith("@") || segments[0].length < 2) {
return "invalid plugin name: scoped ids must use @scope/name format";
}
return null;
}
function matchesExpectedPluginId(params: {
expectedPluginId?: string;
pluginId: string;
manifestPluginId?: string;
npmPluginId: string;
}): boolean {
if (!params.expectedPluginId) {
return true;
}
if (params.expectedPluginId === params.pluginId) {
return true;
}
// Backward compatibility: older install records keyed scoped npm packages by
// their unscoped package name. Preserve update-in-place for those records
// unless the package declares an explicit manifest id override.
return (
!params.manifestPluginId &&
params.pluginId === params.npmPluginId &&
params.expectedPluginId === unscopedPackageName(params.npmPluginId)
);
}
function ensureOpenClawExtensions(params: { manifest: PackageManifest }):
| {
ok: true;
@ -195,6 +245,7 @@ export function resolvePluginInstallDir(pluginId: string, extensionsDir?: string
baseDir: extensionsBase,
id: pluginId,
invalidNameMessage: "invalid plugin name: path traversal detected",
nameEncoder: encodePluginInstallDirName,
});
if (!targetDirResult.ok) {
throw new Error(targetDirResult.error);
@ -233,8 +284,8 @@ async function installPluginFromPackageDir(
}
const extensions = extensionsResult.entries;
const pkgName = typeof manifest.name === "string" ? manifest.name : "";
const npmPluginId = pkgName ? unscopedPackageName(pkgName) : "plugin";
const pkgName = typeof manifest.name === "string" ? manifest.name.trim() : "";
const npmPluginId = pkgName || "plugin";
// Prefer the canonical `id` from openclaw.plugin.json over the npm package name.
// This avoids a latent key-mismatch bug: if the manifest id (e.g. "memory-cognee")
@ -243,7 +294,7 @@ async function installPluginFromPackageDir(
const ocManifestResult = loadPluginManifest(params.packageDir);
const manifestPluginId =
ocManifestResult.ok && ocManifestResult.manifest.id
? unscopedPackageName(ocManifestResult.manifest.id)
? ocManifestResult.manifest.id.trim()
: undefined;
const pluginId = manifestPluginId ?? npmPluginId;
@ -251,7 +302,14 @@ async function installPluginFromPackageDir(
if (pluginIdError) {
return { ok: false, error: pluginIdError };
}
if (params.expectedPluginId && params.expectedPluginId !== pluginId) {
if (
!matchesExpectedPluginId({
expectedPluginId: params.expectedPluginId,
pluginId,
manifestPluginId,
npmPluginId,
})
) {
return {
ok: false,
error: `plugin id mismatch: expected ${params.expectedPluginId}, got ${pluginId}`,
@ -313,6 +371,7 @@ async function installPluginFromPackageDir(
id: pluginId,
invalidNameMessage: "invalid plugin name: path traversal detected",
boundaryLabel: "extensions directory",
nameEncoder: encodePluginInstallDirName,
});
if (!targetDirResult.ok) {
return { ok: false, error: targetDirResult.error };

View File

@ -1692,7 +1692,37 @@ describe("loadOpenClawPlugins", () => {
expect(workspacePlugin?.status).toBe("loaded");
});
it("lets an explicitly trusted workspace plugin shadow a bundled plugin with the same id", () => {
it("keeps scoped and unscoped plugin ids distinct", () => {
useNoBundledPlugins();
const scoped = writePlugin({
id: "@team/shadowed",
body: `module.exports = { id: "@team/shadowed", register() {} };`,
filename: "scoped.cjs",
});
const unscoped = writePlugin({
id: "shadowed",
body: `module.exports = { id: "shadowed", register() {} };`,
filename: "unscoped.cjs",
});
const registry = loadOpenClawPlugins({
cache: false,
config: {
plugins: {
load: { paths: [scoped.file, unscoped.file] },
allow: ["@team/shadowed", "shadowed"],
},
},
});
expect(registry.plugins.find((entry) => entry.id === "@team/shadowed")?.status).toBe("loaded");
expect(registry.plugins.find((entry) => entry.id === "shadowed")?.status).toBe("loaded");
expect(
registry.diagnostics.some((diag) => String(diag.message).includes("duplicate plugin id")),
).toBe(false);
});
it("keeps bundled plugins ahead of trusted workspace duplicates with the same id", () => {
const bundledDir = makeTempDir();
writePlugin({
id: "shadowed",
@ -1719,6 +1749,9 @@ describe("loadOpenClawPlugins", () => {
plugins: {
enabled: true,
allow: ["shadowed"],
entries: {
shadowed: { enabled: true },
},
},
},
});
@ -1726,8 +1759,9 @@ describe("loadOpenClawPlugins", () => {
const entries = registry.plugins.filter((entry) => entry.id === "shadowed");
const loaded = entries.find((entry) => entry.status === "loaded");
const overridden = entries.find((entry) => entry.status === "disabled");
expect(loaded?.origin).toBe("workspace");
expect(overridden?.origin).toBe("bundled");
expect(loaded?.origin).toBe("bundled");
expect(overridden?.origin).toBe("workspace");
expect(overridden?.error).toContain("overridden by bundled plugin");
});
it("warns when loaded non-bundled plugin has no install/load-path provenance", () => {

View File

@ -485,16 +485,20 @@ function resolveCandidateDuplicateRank(params: {
env: params.env,
});
switch (params.candidate.origin) {
case "config":
return 0;
case "workspace":
return 1;
case "global":
return isExplicitInstall ? 2 : 4;
case "bundled":
return 3;
if (params.candidate.origin === "config") {
return 0;
}
if (params.candidate.origin === "global" && isExplicitInstall) {
return 1;
}
if (params.candidate.origin === "bundled") {
// Bundled plugin ids stay reserved unless the operator configured an override.
return 2;
}
if (params.candidate.origin === "workspace") {
return 3;
}
return 4;
}
function compareDuplicateCandidateOrder(params: {

View File

@ -225,6 +225,36 @@ describe("loadPluginManifestRegistry", () => {
).toBe(true);
});
it("reports bundled plugins as the duplicate winner for workspace duplicates", () => {
const bundledDir = makeTempDir();
const workspaceDir = makeTempDir();
const manifest = { id: "shadowed", configSchema: { type: "object" } };
writeManifest(bundledDir, manifest);
writeManifest(workspaceDir, manifest);
const registry = loadPluginManifestRegistry({
cache: false,
candidates: [
createPluginCandidate({
idHint: "shadowed",
rootDir: bundledDir,
origin: "bundled",
}),
createPluginCandidate({
idHint: "shadowed",
rootDir: workspaceDir,
origin: "workspace",
}),
],
});
expect(
registry.diagnostics.some((diag) =>
diag.message.includes("workspace plugin will be overridden by bundled plugin"),
),
).toBe(true);
});
it("suppresses duplicate warning when candidates share the same physical directory via symlink", () => {
const realDir = makeTempDir();
const manifest = { id: "feishu", configSchema: { type: "object" } };

View File

@ -13,7 +13,8 @@ type SeenIdEntry = {
recordIndex: number;
};
// Precedence: config > workspace > explicit-install global > bundled > auto-discovered global
// Canonicalize identical physical plugin roots with the most explicit source.
// This only applies when multiple candidates resolve to the same on-disk plugin.
const PLUGIN_ORIGIN_RANK: Readonly<Record<PluginOrigin, number>> = {
config: 0,
workspace: 1,
@ -167,17 +168,28 @@ function resolveDuplicatePrecedenceRank(params: {
config?: OpenClawConfig;
env: NodeJS.ProcessEnv;
}): number {
if (params.candidate.origin === "global") {
return matchesInstalledPluginRecord({
if (params.candidate.origin === "config") {
return 0;
}
if (
params.candidate.origin === "global" &&
matchesInstalledPluginRecord({
pluginId: params.pluginId,
candidate: params.candidate,
config: params.config,
env: params.env,
})
? 2
: 4;
) {
return 1;
}
return PLUGIN_ORIGIN_RANK[params.candidate.origin];
if (params.candidate.origin === "bundled") {
// Bundled plugin ids are reserved unless the operator explicitly overrides them.
return 2;
}
if (params.candidate.origin === "workspace") {
return 3;
}
return 4;
}
export function loadPluginManifestRegistry(params: {

View File

@ -156,6 +156,63 @@ describe("updateNpmInstalledPlugins", () => {
},
]);
});
it("migrates legacy unscoped install keys when a scoped npm package updates", async () => {
installPluginFromNpmSpecMock.mockResolvedValue({
ok: true,
pluginId: "@openclaw/voice-call",
targetDir: "/tmp/openclaw-voice-call",
version: "0.0.2",
extensions: ["index.ts"],
});
const { updateNpmInstalledPlugins } = await import("./update.js");
const result = await updateNpmInstalledPlugins({
config: {
plugins: {
allow: ["voice-call"],
deny: ["voice-call"],
slots: { memory: "voice-call" },
entries: {
"voice-call": {
enabled: false,
hooks: { allowPromptInjection: false },
},
},
installs: {
"voice-call": {
source: "npm",
spec: "@openclaw/voice-call",
installPath: "/tmp/voice-call",
},
},
},
},
pluginIds: ["voice-call"],
});
expect(installPluginFromNpmSpecMock).toHaveBeenCalledWith(
expect.objectContaining({
spec: "@openclaw/voice-call",
expectedPluginId: "voice-call",
}),
);
expect(result.config.plugins?.allow).toEqual(["@openclaw/voice-call"]);
expect(result.config.plugins?.deny).toEqual(["@openclaw/voice-call"]);
expect(result.config.plugins?.slots?.memory).toBe("@openclaw/voice-call");
expect(result.config.plugins?.entries?.["@openclaw/voice-call"]).toEqual({
enabled: false,
hooks: { allowPromptInjection: false },
});
expect(result.config.plugins?.entries?.["voice-call"]).toBeUndefined();
expect(result.config.plugins?.installs?.["@openclaw/voice-call"]).toMatchObject({
source: "npm",
spec: "@openclaw/voice-call",
installPath: "/tmp/openclaw-voice-call",
version: "0.0.2",
});
expect(result.config.plugins?.installs?.["voice-call"]).toBeUndefined();
});
});
describe("syncPluginsForUpdateChannel", () => {

View File

@ -172,6 +172,79 @@ function buildLoadPathHelpers(existing: string[], env: NodeJS.ProcessEnv = proce
};
}
function replacePluginIdInList(
entries: string[] | undefined,
fromId: string,
toId: string,
): string[] | undefined {
if (!entries || entries.length === 0 || fromId === toId) {
return entries;
}
const next: string[] = [];
for (const entry of entries) {
const value = entry === fromId ? toId : entry;
if (!next.includes(value)) {
next.push(value);
}
}
return next;
}
function migratePluginConfigId(cfg: OpenClawConfig, fromId: string, toId: string): OpenClawConfig {
if (fromId === toId) {
return cfg;
}
const installs = cfg.plugins?.installs;
const entries = cfg.plugins?.entries;
const slots = cfg.plugins?.slots;
const allow = replacePluginIdInList(cfg.plugins?.allow, fromId, toId);
const deny = replacePluginIdInList(cfg.plugins?.deny, fromId, toId);
const nextInstalls = installs ? { ...installs } : undefined;
if (nextInstalls && fromId in nextInstalls) {
const record = nextInstalls[fromId];
if (record && !(toId in nextInstalls)) {
nextInstalls[toId] = record;
}
delete nextInstalls[fromId];
}
const nextEntries = entries ? { ...entries } : undefined;
if (nextEntries && fromId in nextEntries) {
const entry = nextEntries[fromId];
if (entry) {
nextEntries[toId] = nextEntries[toId]
? {
...entry,
...nextEntries[toId],
}
: entry;
}
delete nextEntries[fromId];
}
const nextSlots =
slots?.memory === fromId
? {
...slots,
memory: toId,
}
: slots;
return {
...cfg,
plugins: {
...cfg.plugins,
allow,
deny,
entries: nextEntries,
installs: nextInstalls,
slots: nextSlots,
},
};
}
function createPluginUpdateIntegrityDriftHandler(params: {
pluginId: string;
dryRun: boolean;
@ -362,9 +435,14 @@ export async function updateNpmInstalledPlugins(params: {
continue;
}
const resolvedPluginId = result.pluginId;
if (resolvedPluginId !== pluginId) {
next = migratePluginConfigId(next, pluginId, resolvedPluginId);
}
const nextVersion = result.version ?? (await readInstalledPackageVersion(result.targetDir));
next = recordPluginInstall(next, {
pluginId,
pluginId: resolvedPluginId,
source: "npm",
spec: record.spec,
installPath: result.targetDir,