fix: repair bundled plugin shadow cleanup

This commit is contained in:
Peter Steinberger
2026-05-04 10:17:37 +01:00
parent dade5f9133
commit b7ce9439e7
9 changed files with 257 additions and 17 deletions

View File

@@ -76,7 +76,7 @@ Docs: https://docs.openclaw.ai
- Canvas host: preserve the Gateway TLS scheme in browser canvas host URLs and startup mount logs, so direct HTTPS gateways do not advertise insecure canvas links. Thanks @vincentkoc.
- WhatsApp/login: route login success and failure messages through the injected runtime, so setup/onboarding surfaces capture all login output instead of only the QR. Thanks @vincentkoc.
- Google Chat: create an isolated Google auth transport per auth client, so google-auth-library interceptor mutations do not accumulate across webhook verification and access-token clients. Thanks @vincentkoc.
- Doctor/plugins: remove orphaned managed npm copies of bundled `@openclaw/*` plugins during `doctor --fix`, so stale package manifests cannot shadow the current bundled plugin config schema.
- Doctor/plugins: remove orphaned or recovered managed npm copies of bundled `@openclaw/*` plugins during `doctor --fix`, so stale package manifests cannot shadow the current bundled plugin config schema.
- Control UI/performance: cap long-task and long-animation-frame diagnostics in the shared event log, so slow-render telemetry does not evict gateway/plugin events from the Debug and Overview views. Thanks @vincentkoc.
- Gateway/startup: log the canvas host mount only after the HTTP server has bound, so startup logs no longer report the canvas host as mounted before it can serve requests.
- Control UI/i18n: render the Sessions active filter tooltip with the configured minute count in every locale and make the i18n check reject placeholder drift. Thanks @BunsDev.

View File

@@ -387,7 +387,7 @@ The local plugin registry is OpenClaw's persisted cold read model for installed
Use `plugins registry` to inspect whether the persisted registry is present, current, or stale. Use `--refresh` to rebuild it from the persisted plugin index, config policy, and manifest/package metadata. This is a repair path, not a runtime activation path.
`openclaw doctor --fix` also repairs registry-adjacent managed npm drift: if an orphaned `@openclaw/*` package under the managed plugin npm root shadows a bundled plugin, doctor removes that stale package and rebuilds the registry so startup validates against the bundled manifest.
`openclaw doctor --fix` also repairs registry-adjacent managed npm drift: if an orphaned or recovered `@openclaw/*` package under the managed plugin npm root shadows a bundled plugin, doctor removes that stale package and rebuilds the registry so startup validates against the bundled manifest.
<Warning>
`OPENCLAW_DISABLE_PERSISTED_PLUGIN_REGISTRY=1` is a deprecated break-glass compatibility switch for registry read failures. Prefer `plugins registry --refresh` or `openclaw doctor --fix`; the env fallback is only for emergency startup recovery while the migration rolls out.

View File

@@ -344,7 +344,7 @@ That stages grounded durable candidates into the short-term dreaming store while
When sandboxing is enabled, doctor checks Docker images and offers to build or switch to legacy names if the current image is missing.
</Accordion>
<Accordion title="7b. Plugin install cleanup">
Doctor removes legacy OpenClaw-generated plugin dependency staging state in `openclaw doctor --fix` / `openclaw doctor --repair` mode. This covers stale generated dependency roots, old install-stage directories, package-local debris from earlier bundled-plugin dependency repair code, and orphaned managed npm copies of bundled `@openclaw/*` plugins that can shadow the current bundled manifest.
Doctor removes legacy OpenClaw-generated plugin dependency staging state in `openclaw doctor --fix` / `openclaw doctor --repair` mode. This covers stale generated dependency roots, old install-stage directories, package-local debris from earlier bundled-plugin dependency repair code, and orphaned or recovered managed npm copies of bundled `@openclaw/*` plugins that can shadow the current bundled manifest.
Doctor can also reinstall configured downloadable plugins when the config references them but the local plugin registry cannot find them. For the 2026.5.2 bundled-plugin externalization, doctor automatically installs downloadable plugins that the existing config already uses and then relies on `meta.lastTouchedVersion` to run that release pass only once. Gateway startup and config reload do not run package managers; plugin installs remain explicit doctor/install/update work.

View File

@@ -188,6 +188,28 @@ function createCurrentIndex(): InstalledPluginIndex {
};
}
function createCurrentIndexWithNpmRecord(params: {
pluginId: string;
packageName: string;
packageDir: string;
version: string;
}): InstalledPluginIndex {
return {
...createCurrentIndex(),
installRecords: {
[params.pluginId]: {
source: "npm",
spec: `${params.packageName}@${params.version}`,
installPath: params.packageDir,
version: params.version,
resolvedName: params.packageName,
resolvedVersion: params.version,
resolvedSpec: `${params.packageName}@${params.version}`,
},
},
};
}
describe("maybeRepairPluginRegistryState", () => {
it("refreshes an existing registry during repair", async () => {
const stateDir = makeTempDir();
@@ -334,6 +356,65 @@ describe("maybeRepairPluginRegistryState", () => {
);
});
it("removes recovered npm install records when a managed package shadows a bundled plugin", async () => {
const stateDir = makeTempDir();
const bundledDir = path.join(stateDir, "bundled", "google-meet");
fs.mkdirSync(bundledDir, { recursive: true });
const managed = createManagedNpmPlugin({
stateDir,
id: "google-meet",
packageName: "@openclaw/google-meet",
version: "2026.5.3",
});
await writePersistedInstalledPluginIndex(
createCurrentIndexWithNpmRecord({
pluginId: "google-meet",
packageName: "@openclaw/google-meet",
packageDir: managed.packageDir,
version: "2026.5.3",
}),
{ stateDir },
);
await maybeRepairPluginRegistryState({
stateDir,
candidates: [
createBundledCandidate({
rootDir: bundledDir,
id: "google-meet",
packageName: "@openclaw/google-meet",
version: "2026.5.3",
}),
],
env: hermeticEnv(),
config: {
plugins: {
allow: ["google-meet"],
entries: {
"google-meet": {
enabled: true,
config: {},
},
},
},
},
prompter: { shouldRepair: true },
});
expect(fs.existsSync(managed.packageDir)).toBe(false);
await expect(readPersistedInstalledPluginIndex({ stateDir })).resolves.toMatchObject({
installRecords: {},
refreshReason: "migration",
plugins: [
expect.objectContaining({
pluginId: "google-meet",
origin: "bundled",
rootDir: bundledDir,
}),
],
});
});
it("removes stale managed npm packages from the package lock during repair", async () => {
const stateDir = makeTempDir();
const bundledDir = path.join(stateDir, "bundled", "google-meet");

View File

@@ -5,7 +5,6 @@ import type { OpenClawConfig } from "../config/types.openclaw.js";
import { saveJsonFile } from "../infra/json-file.js";
import { resolveDefaultPluginNpmDir } from "../plugins/install-paths.js";
import type { InstalledPluginIndexRecordStoreOptions } from "../plugins/installed-plugin-index-records.js";
import { readPersistedInstalledPluginIndexInstallRecordsSync } from "../plugins/installed-plugin-index-records.js";
import { loadInstalledPluginIndex } from "../plugins/installed-plugin-index.js";
import { refreshPluginRegistry } from "../plugins/plugin-registry.js";
import { note } from "../terminal/note.js";
@@ -81,7 +80,6 @@ function readPluginManifestId(packageDir: string): string | undefined {
function listStaleManagedNpmBundledPlugins(
params: PluginRegistryDoctorRepairParams,
): StaleManagedNpmBundledPlugin[] {
const persistedInstallRecords = readPersistedInstalledPluginIndexInstallRecordsSync(params) ?? {};
const currentBundled = loadInstalledPluginIndex({
...params,
installRecords: {},
@@ -109,10 +107,6 @@ function listStaleManagedNpmBundledPlugins(
if (!pluginId || pluginId !== bundled.pluginId) {
continue;
}
const persistedRecord = persistedInstallRecords[pluginId];
if (persistedRecord?.source === "npm") {
continue;
}
stale.push({
pluginId,
packageName,
@@ -195,7 +189,7 @@ function removeManagedNpmPackageLockDependency(params: {
}
}
function maybeRepairStaleManagedNpmBundledPlugins(
export function maybeRepairStaleManagedNpmBundledPlugins(
params: PluginRegistryDoctorRepairParams,
): boolean {
const stale = listStaleManagedNpmBundledPlugins(params);

View File

@@ -4,6 +4,7 @@ import { runDoctorRepairSequence } from "./repair-sequencing.js";
const mocks = vi.hoisted(() => ({
applyPluginAutoEnable: vi.fn(),
maybeRepairStaleManagedNpmBundledPlugins: vi.fn(),
maybeRepairStalePluginConfig: vi.fn(),
repairMissingConfiguredPluginInstalls: vi.fn(),
}));
@@ -12,6 +13,10 @@ vi.mock("../../config/plugin-auto-enable.js", () => ({
applyPluginAutoEnable: mocks.applyPluginAutoEnable,
}));
vi.mock("../doctor-plugin-registry.js", () => ({
maybeRepairStaleManagedNpmBundledPlugins: mocks.maybeRepairStaleManagedNpmBundledPlugins,
}));
vi.mock("./shared/missing-configured-plugin-install.js", () => ({
repairMissingConfiguredPluginInstalls: mocks.repairMissingConfiguredPluginInstalls,
}));
@@ -145,6 +150,7 @@ describe("doctor repair sequencing", () => {
config: params.config,
changes: [],
}));
mocks.maybeRepairStaleManagedNpmBundledPlugins.mockReturnValue(false);
mocks.repairMissingConfiguredPluginInstalls.mockResolvedValue({
changes: [],
warnings: [],
@@ -228,6 +234,54 @@ describe("doctor repair sequencing", () => {
expect(result.warningNotes.join("\n")).not.toContain("\r");
});
it("removes managed npm bundled-plugin shadows before missing plugin install repair", async () => {
const events: string[] = [];
mocks.maybeRepairStaleManagedNpmBundledPlugins.mockImplementation(() => {
events.push("cleanup");
return true;
});
mocks.repairMissingConfiguredPluginInstalls.mockImplementation(async () => {
events.push("missing-installs");
return { changes: [], warnings: [] };
});
await runDoctorRepairSequence({
state: {
cfg: {
plugins: {
entries: {
"google-meet": { enabled: true },
},
},
} as OpenClawConfig,
candidate: {
plugins: {
entries: {
"google-meet": { enabled: true },
},
},
} as OpenClawConfig,
pendingChanges: false,
fixHints: [],
},
doctorFixCommand: "openclaw doctor --fix",
});
expect(events).toEqual(["cleanup", "missing-installs"]);
expect(mocks.maybeRepairStaleManagedNpmBundledPlugins).toHaveBeenCalledWith(
expect.objectContaining({
config: expect.objectContaining({
plugins: expect.objectContaining({
entries: expect.objectContaining({
"google-meet": { enabled: true },
}),
}),
}),
prompter: { shouldRepair: true },
}),
);
});
it("emits Discord warnings when unsafe numeric ids block repair", async () => {
const result = await runDoctorRepairSequence({
state: {

View File

@@ -1,5 +1,6 @@
import { applyPluginAutoEnable } from "../../config/plugin-auto-enable.js";
import { sanitizeForLog } from "../../terminal/ansi.js";
import { maybeRepairStaleManagedNpmBundledPlugins } from "../doctor-plugin-registry.js";
import { maybeRepairAllowlistPolicyAllowFrom } from "./shared/allowlist-policy-repair.js";
import { maybeRepairBundledPluginLoadPaths } from "./shared/bundled-plugin-load-paths.js";
import {
@@ -67,6 +68,11 @@ export async function runDoctorRepairSequence(params: {
}
applyMutation(maybeRepairOpenPolicyAllowFrom(state.candidate));
applyMutation(maybeRepairBundledPluginLoadPaths(state.candidate, env));
maybeRepairStaleManagedNpmBundledPlugins({
config: state.candidate,
env,
prompter: { shouldRepair: true },
});
const missingConfiguredPluginInstallRepair = await repairMissingConfiguredPluginInstalls({
cfg: state.candidate,
env,

View File

@@ -5,6 +5,7 @@ const mocks = vi.hoisted(() => ({
installPluginFromNpmSpec: vi.fn(),
listChannelPluginCatalogEntries: vi.fn(),
listOfficialExternalPluginCatalogEntries: vi.fn(),
loadInstalledPluginIndex: vi.fn(),
loadInstalledPluginIndexInstallRecords: vi.fn(),
loadPluginMetadataSnapshot: vi.fn(),
getOfficialExternalPluginCatalogManifest: vi.fn(
@@ -33,6 +34,11 @@ vi.mock("../../../plugins/installed-plugin-index-records.js", () => ({
mocks.writePersistedInstalledPluginIndexInstallRecords,
}));
vi.mock("../../../plugins/installed-plugin-index.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../../../plugins/installed-plugin-index.js")>()),
loadInstalledPluginIndex: mocks.loadInstalledPluginIndex,
}));
vi.mock("../../../plugins/install-paths.js", () => ({
resolveDefaultPluginExtensionsDir: mocks.resolveDefaultPluginExtensionsDir,
}));
@@ -76,6 +82,11 @@ describe("repairMissingConfiguredPluginInstalls", () => {
plugins: [],
diagnostics: [],
});
mocks.loadInstalledPluginIndex.mockReturnValue({
plugins: [],
diagnostics: [],
installRecords: {},
});
mocks.loadInstalledPluginIndexInstallRecords.mockResolvedValue({});
mocks.listChannelPluginCatalogEntries.mockReturnValue([]);
mocks.listOfficialExternalPluginCatalogEntries.mockReturnValue([]);
@@ -663,6 +674,75 @@ describe("repairMissingConfiguredPluginInstalls", () => {
});
});
it("uses current bundled discovery to remove records before stale snapshots can reinstall official plugins", async () => {
const records = {
"google-meet": {
source: "npm",
spec: "@openclaw/google-meet",
resolvedName: "@openclaw/google-meet",
installPath: "/missing/google-meet",
},
};
mocks.loadInstalledPluginIndexInstallRecords.mockResolvedValue(records);
mocks.loadPluginMetadataSnapshot.mockReturnValue({
plugins: [
{
id: "google-meet",
origin: "npm",
packageName: "@openclaw/google-meet",
},
],
diagnostics: [],
});
mocks.loadInstalledPluginIndex.mockReturnValue({
plugins: [
{
pluginId: "google-meet",
origin: "bundled",
packageName: "@openclaw/google-meet",
},
],
diagnostics: [],
installRecords: {},
});
mocks.listOfficialExternalPluginCatalogEntries.mockReturnValue([
{
id: "google-meet",
label: "Google Meet",
install: { npmSpec: "@openclaw/google-meet" },
openclaw: {
id: "google-meet",
install: { npmSpec: "@openclaw/google-meet" },
},
},
]);
const { repairMissingConfiguredPluginInstalls } =
await import("./missing-configured-plugin-install.js");
const result = await repairMissingConfiguredPluginInstalls({
cfg: {
plugins: {
entries: {
"google-meet": { enabled: true },
},
},
},
env: {},
});
expect(mocks.installPluginFromNpmSpec).not.toHaveBeenCalled();
expect(mocks.writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith(
{},
{
env: {},
},
);
expect(result).toEqual({
changes: ['Removed stale managed install record for bundled plugin "google-meet".'],
warnings: [],
});
});
it.each([
[
"npm",

View File

@@ -16,9 +16,9 @@ import { resolveDefaultPluginExtensionsDir } from "../../../plugins/install-path
import { installPluginFromNpmSpec } from "../../../plugins/install.js";
import { loadInstalledPluginIndexInstallRecords } from "../../../plugins/installed-plugin-index-records.js";
import { writePersistedInstalledPluginIndexInstallRecords } from "../../../plugins/installed-plugin-index-records.js";
import { loadInstalledPluginIndex } from "../../../plugins/installed-plugin-index.js";
import { buildNpmResolutionInstallFields } from "../../../plugins/installs.js";
import { loadManifestMetadataSnapshot } from "../../../plugins/manifest-contract-eligibility.js";
import type { PluginManifestRecord } from "../../../plugins/manifest-registry.js";
import type { PluginPackageInstall } from "../../../plugins/manifest.js";
import {
listOfficialExternalPluginCatalogEntries,
@@ -44,6 +44,11 @@ type DownloadableInstallCandidate = {
defaultChoice?: PluginPackageInstall["defaultChoice"];
};
type BundledPluginPackageDescriptor = {
name?: string;
packageName?: string;
};
const RUNTIME_PLUGIN_INSTALL_CANDIDATES: readonly DownloadableInstallCandidate[] = [
{
pluginId: "acpx",
@@ -417,7 +422,7 @@ function isUpdatePackageDoctorPass(env: NodeJS.ProcessEnv): boolean {
function recordMatchesBundledPackage(
record: PluginInstallRecord,
bundled: PluginManifestRecord,
bundled: BundledPluginPackageDescriptor,
): boolean {
const packageName = bundled.packageName?.trim() || bundled.name?.trim();
if (!packageName) {
@@ -598,18 +603,35 @@ async function repairMissingPluginInstalls(params: {
config: params.cfg,
env,
});
const knownIds = new Set(snapshot.plugins.map((plugin) => plugin.id));
const currentBundledPlugins = loadInstalledPluginIndex({
config: params.cfg,
env,
installRecords: {},
}).plugins.filter((plugin) => plugin.origin === "bundled");
const knownIds = new Set([
...snapshot.plugins.map((plugin) => plugin.id),
...currentBundledPlugins.map((plugin) => plugin.pluginId),
]);
const configuredChannelOwnerPluginIds = collectEffectiveConfiguredChannelOwnerPluginIds({
cfg: params.cfg,
env,
snapshot,
configuredChannelIds: params.channelIds,
});
const bundledPluginsById = new Map(
snapshot.plugins
const bundledPluginsById = new Map<string, BundledPluginPackageDescriptor>([
...snapshot.plugins
.filter((plugin) => plugin.origin === "bundled")
.map((plugin) => [plugin.id, plugin]),
);
.map((plugin) => [plugin.id, plugin] as const),
...currentBundledPlugins.map(
(plugin) =>
[
plugin.pluginId,
{
packageName: plugin.packageName,
},
] as const,
),
]);
const configuredPluginIdsWithStaleDescriptors =
collectConfiguredPluginIdsWithMissingChannelConfigDescriptors({
snapshot,
@@ -724,6 +746,9 @@ async function repairMissingPluginInstalls(params: {
? new Set([...(params.blockedPluginIds ?? []), ...deferredPluginIds])
: params.blockedPluginIds,
})) {
if (bundledPluginsById.has(candidate.pluginId)) {
continue;
}
const hasUsableRecord =
Object.hasOwn(nextRecords, candidate.pluginId) &&
!isInstalledRecordMissingOnDisk(nextRecords[candidate.pluginId], env);