mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:30:42 +00:00
fix: repair stale managed plugin shadows
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user