mirror of https://github.com/openclaw/openclaw.git
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:
parent
0c7ae04262
commit
8d44b16b7c
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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" } };
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue