fix: repair stale managed plugin shadows

This commit is contained in:
Peter Steinberger
2026-05-04 09:16:54 +01:00
parent be21d64d08
commit 281b5bd511
5 changed files with 361 additions and 2 deletions

View File

@@ -65,6 +65,7 @@ Docs: https://docs.openclaw.ai
- Control UI/Talk: retry from a failed realtime Talk session on the next Talk click instead of requiring a separate stale-session stop click first. Thanks @vincentkoc.
- 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.
- 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.
- 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.
- Web fetch: late-bind `web_fetch` config and provider fallback metadata from the active runtime snapshot, matching `web_search` so long-lived tools do not use stale fetch provider settings. Thanks @vincentkoc.

View File

@@ -387,6 +387,8 @@ 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.
<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.
</Warning>

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, and package-local debris from earlier bundled-plugin dependency repair code.
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 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

@@ -60,6 +60,88 @@ function createCandidate(rootDir: string, id = "demo"): PluginCandidate {
};
}
function createBundledCandidate(params: {
rootDir: string;
id: string;
packageName: string;
version: string;
}): PluginCandidate {
fs.writeFileSync(
path.join(params.rootDir, "index.ts"),
"throw new Error('runtime entry should not load during doctor registry repair');\n",
"utf8",
);
fs.writeFileSync(
path.join(params.rootDir, "openclaw.plugin.json"),
JSON.stringify({
id: params.id,
name: params.id,
configSchema: { type: "object" },
providers: [params.id],
}),
"utf8",
);
fs.writeFileSync(
path.join(params.rootDir, "package.json"),
JSON.stringify({
name: params.packageName,
version: params.version,
}),
"utf8",
);
return {
idHint: params.id,
source: path.join(params.rootDir, "index.ts"),
rootDir: params.rootDir,
origin: "bundled",
packageName: params.packageName,
packageVersion: params.version,
};
}
function createManagedNpmPlugin(params: {
stateDir: string;
id: string;
packageName: string;
version: string;
}) {
const npmRoot = path.join(params.stateDir, "npm");
const packageDir = path.join(npmRoot, "node_modules", params.packageName);
fs.mkdirSync(packageDir, { recursive: true });
fs.writeFileSync(
path.join(npmRoot, "package.json"),
JSON.stringify({
dependencies: {
[params.packageName]: params.version,
},
}),
"utf8",
);
fs.writeFileSync(
path.join(packageDir, "package.json"),
JSON.stringify({
name: params.packageName,
version: params.version,
openclaw: {
extensions: ["."],
},
}),
"utf8",
);
fs.writeFileSync(
path.join(packageDir, "openclaw.plugin.json"),
JSON.stringify({
id: params.id,
name: params.id,
configSchema: {
type: "object",
},
}),
"utf8",
);
return { npmRoot, packageDir };
}
function createCurrentIndex(): InstalledPluginIndex {
return {
version: 1,
@@ -115,4 +197,108 @@ describe("maybeRepairPluginRegistryState", () => {
expect(nextConfig).toEqual({});
expect(vi.mocked(note).mock.calls.join("\n")).toContain(DISABLE_PLUGIN_REGISTRY_MIGRATION_ENV);
});
it("warns about stale managed npm packages that shadow bundled plugins", async () => {
const stateDir = makeTempDir();
const bundledDir = path.join(stateDir, "bundled", "google-meet");
fs.mkdirSync(bundledDir, { recursive: true });
createManagedNpmPlugin({
stateDir,
id: "google-meet",
packageName: "@openclaw/google-meet",
version: "2026.5.2",
});
await writePersistedInstalledPluginIndex(createCurrentIndex(), { 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: false },
});
expect(vi.mocked(note).mock.calls.join("\n")).toContain(
"Managed npm plugin packages shadow bundled plugins",
);
expect(vi.mocked(note).mock.calls.join("\n")).toContain("@openclaw/google-meet@2026.5.2");
expect(
fs.existsSync(path.join(stateDir, "npm", "node_modules", "@openclaw", "google-meet")),
).toBe(true);
});
it("removes stale managed npm packages that shadow bundled plugins during repair", async () => {
const stateDir = makeTempDir();
const bundledDir = path.join(stateDir, "bundled", "google-meet");
fs.mkdirSync(bundledDir, { recursive: true });
createManagedNpmPlugin({
stateDir,
id: "google-meet",
packageName: "@openclaw/google-meet",
version: "2026.5.2",
});
await writePersistedInstalledPluginIndex(createCurrentIndex(), { 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(path.join(stateDir, "npm", "node_modules", "@openclaw", "google-meet")),
).toBe(false);
expect(
JSON.parse(fs.readFileSync(path.join(stateDir, "npm", "package.json"), "utf8")),
).not.toHaveProperty("dependencies");
await expect(readPersistedInstalledPluginIndex({ stateDir })).resolves.toMatchObject({
refreshReason: "migration",
plugins: [
expect.objectContaining({
pluginId: "google-meet",
origin: "bundled",
rootDir: bundledDir,
}),
],
});
expect(vi.mocked(note).mock.calls.join("\n")).toContain(
"Removed stale managed npm plugin package",
);
});
});

View File

@@ -1,6 +1,12 @@
import fs from "node:fs";
import path from "node:path";
import { formatCliCommand } from "../cli/command-format.js";
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";
import { shortenHomePath } from "../utils.js";
@@ -18,6 +24,169 @@ type PluginRegistryDoctorRepairParams = Omit<PluginRegistryInstallMigrationParam
prompter: Pick<DoctorPrompter, "shouldRepair">;
};
type StaleManagedNpmBundledPlugin = {
pluginId: string;
packageName: string;
packageDir: string;
npmRoot: string;
version?: string;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function readJsonObject(filePath: string): Record<string, unknown> | null {
try {
const parsed = JSON.parse(fs.readFileSync(filePath, "utf8")) as unknown;
return isRecord(parsed) ? parsed : null;
} catch {
return null;
}
}
function readStringMap(value: unknown): Record<string, string> {
if (!isRecord(value)) {
return {};
}
const result: Record<string, string> = {};
for (const [key, raw] of Object.entries(value)) {
if (typeof raw === "string" && raw.trim()) {
result[key] = raw.trim();
}
}
return result;
}
function readPackageVersion(packageDir: string): string | undefined {
const packageJson = readJsonObject(path.join(packageDir, "package.json"));
const version = packageJson?.version;
return typeof version === "string" && version.trim() ? version.trim() : undefined;
}
function readPluginManifestId(packageDir: string): string | undefined {
const manifest = readJsonObject(path.join(packageDir, "openclaw.plugin.json"));
const id = manifest?.id;
return typeof id === "string" && id.trim() ? id.trim() : undefined;
}
function listStaleManagedNpmBundledPlugins(
params: PluginRegistryDoctorRepairParams,
): StaleManagedNpmBundledPlugin[] {
const persistedInstallRecords = readPersistedInstalledPluginIndexInstallRecordsSync(params) ?? {};
const currentBundled = loadInstalledPluginIndex({
...params,
installRecords: {},
}).plugins.filter((plugin) => plugin.origin === "bundled" && plugin.packageName);
const bundledByPackage = new Map(
currentBundled.map((plugin) => [plugin.packageName, plugin] as const),
);
const npmRoot = params.stateDir
? path.join(params.stateDir, "npm")
: resolveDefaultPluginNpmDir(params.env);
const npmPackageJsonPath = path.join(npmRoot, "package.json");
const dependencies = readStringMap(readJsonObject(npmPackageJsonPath)?.dependencies);
const stale: StaleManagedNpmBundledPlugin[] = [];
for (const packageName of Object.keys(dependencies).toSorted()) {
if (!packageName.startsWith("@openclaw/")) {
continue;
}
const bundled = bundledByPackage.get(packageName);
if (!bundled) {
continue;
}
const packageDir = path.join(npmRoot, "node_modules", packageName);
const pluginId = readPluginManifestId(packageDir);
if (!pluginId || pluginId !== bundled.pluginId) {
continue;
}
const persistedRecord = persistedInstallRecords[pluginId];
if (persistedRecord?.source === "npm") {
continue;
}
stale.push({
pluginId,
packageName,
packageDir,
npmRoot,
...(readPackageVersion(packageDir) ? { version: readPackageVersion(packageDir) } : {}),
});
}
return stale;
}
function removeManagedNpmDependency(params: {
npmRoot: string;
packageName: string;
packageDir: string;
}): void {
const npmPackageJsonPath = path.join(params.npmRoot, "package.json");
const packageJson = readJsonObject(npmPackageJsonPath) ?? {};
const dependencies = readStringMap(packageJson.dependencies);
delete dependencies[params.packageName];
const nextPackageJson =
Object.keys(dependencies).length === 0
? (() => {
const { dependencies: _dependencies, ...rest } = packageJson;
return rest;
})()
: {
...packageJson,
dependencies,
};
saveJsonFile(npmPackageJsonPath, nextPackageJson);
fs.rmSync(params.packageDir, { recursive: true, force: true });
const scopeDir = path.dirname(params.packageDir);
if (path.basename(path.dirname(scopeDir)) === "node_modules") {
try {
fs.rmdirSync(scopeDir);
} catch {
// Other packages can still live under the scope directory.
}
}
}
function maybeRepairStaleManagedNpmBundledPlugins(
params: PluginRegistryDoctorRepairParams,
): boolean {
const stale = listStaleManagedNpmBundledPlugins(params);
if (stale.length === 0) {
return false;
}
if (!params.prompter.shouldRepair) {
note(
[
"Managed npm plugin packages shadow bundled plugins:",
...stale.map(
(plugin) =>
`- ${plugin.pluginId}: ${plugin.packageName}${plugin.version ? `@${plugin.version}` : ""}`,
),
`Repair with ${formatCliCommand("openclaw doctor --fix")} to remove stale managed npm packages and rebuild the plugin registry.`,
].join("\n"),
"Plugin registry",
);
return false;
}
for (const plugin of stale) {
removeManagedNpmDependency(plugin);
}
note(
[
"Removed stale managed npm plugin package(s) shadowing bundled plugins:",
...stale.map(
(plugin) =>
`- ${plugin.pluginId}: ${plugin.packageName}${plugin.version ? `@${plugin.version}` : ""}`,
),
].join("\n"),
"Plugin registry",
);
return true;
}
export async function maybeRepairPluginRegistryState(
params: PluginRegistryDoctorRepairParams,
): Promise<OpenClawConfig> {
@@ -37,6 +206,7 @@ export async function maybeRepairPluginRegistryState(
...params,
config: params.config,
};
const removedStaleManagedNpmBundledPlugins = maybeRepairStaleManagedNpmBundledPlugins(params);
if (!params.prompter.shouldRepair) {
if (preflight.action === "migrate") {
note(
@@ -63,7 +233,7 @@ export async function maybeRepairPluginRegistryState(
return params.config;
}
if (preflight.action === "skip-existing") {
if (preflight.action === "skip-existing" || removedStaleManagedNpmBundledPlugins) {
const index = await refreshPluginRegistry({
...migrationParams,
reason: "migration",