From 8d44b16b7cc7705dc878ef7cc9fbcba6dcb9e179 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 09:07:10 -0700 Subject: [PATCH] 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 --- CHANGELOG.md | 1 + src/infra/install-safe-path.ts | 4 +- src/infra/install-target.ts | 2 + src/plugins/install.test.ts | 81 +++++++++++++++++++++++---- src/plugins/install.ts | 77 ++++++++++++++++++++++--- src/plugins/loader.test.ts | 40 ++++++++++++- src/plugins/loader.ts | 22 +++++--- src/plugins/manifest-registry.test.ts | 30 ++++++++++ src/plugins/manifest-registry.ts | 24 ++++++-- src/plugins/update.test.ts | 57 +++++++++++++++++++ src/plugins/update.ts | 80 +++++++++++++++++++++++++- 11 files changed, 377 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 95a68bc92cb..6b05fec4ff7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/infra/install-safe-path.ts b/src/infra/install-safe-path.ts index 13cc88562ed..a2f012e70fb 100644 --- a/src/infra/install-safe-path.ts +++ b/src/infra/install-safe-path.ts @@ -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); diff --git a/src/infra/install-target.ts b/src/infra/install-target.ts index 38dd103c01c..dd954a92112 100644 --- a/src/infra/install-target.ts +++ b/src/infra/install-target.ts @@ -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 }; diff --git a/src/plugins/install.test.ts b/src/plugins/install.test.ts index 5f698a8e64b..db2fcfaf8f9 100644 --- a/src/plugins/install.test.ts +++ b/src/plugins/install.test.ts @@ -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>, 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); }); }); diff --git a/src/plugins/install.ts b/src/plugins/install.ts index e6e107877cf..ab87377d32e 100644 --- a/src/plugins/install.ts +++ b/src/plugins/install.ts @@ -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 }; diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 4771d98aa31..c37cfbfd46c 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -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", () => { diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 698918964f9..253ad63afc4 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -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: { diff --git a/src/plugins/manifest-registry.test.ts b/src/plugins/manifest-registry.test.ts index bbdc8020d6e..214c9b3b23f 100644 --- a/src/plugins/manifest-registry.test.ts +++ b/src/plugins/manifest-registry.test.ts @@ -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" } }; diff --git a/src/plugins/manifest-registry.ts b/src/plugins/manifest-registry.ts index 79fb3facf8e..285b3042004 100644 --- a/src/plugins/manifest-registry.ts +++ b/src/plugins/manifest-registry.ts @@ -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> = { 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: { diff --git a/src/plugins/update.test.ts b/src/plugins/update.test.ts index 65ef9966a83..4d3b72ed65d 100644 --- a/src/plugins/update.test.ts +++ b/src/plugins/update.test.ts @@ -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", () => { diff --git a/src/plugins/update.ts b/src/plugins/update.ts index b214558bc57..af6434e84cc 100644 --- a/src/plugins/update.ts +++ b/src/plugins/update.ts @@ -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,