fix(plugins): recover managed-npm external plugins after package-manager upgrade

Co-authored-by: pingu <pingu@penchan.co>
This commit is contained in:
Penchan
2026-05-05 07:31:35 +08:00
committed by GitHub
parent 9eed48fde5
commit d0c7f91ed1
4 changed files with 261 additions and 3 deletions

View File

@@ -83,6 +83,7 @@ Docs: https://docs.openclaw.ai
- Plugins/update: treat OpenClaw CalVer correction versions like `2026.5.3-1` as satisfying base plugin API ranges, so correction builds can install plugins that require the base runtime API. Fixes #77293. (#77450) Thanks @p3nchan.
- Discord/Gateway startup: retry Discord READY waits with backoff, defer startup `sessions.list` and native approval readiness failures until sidecars recover, and preserve component-only Discord payloads when final reply scrubbing removes all text. (#77478) Thanks @NikolaFC.
- CLI/launcher: forward termination signals to compile-cache respawn children, so killing a wrapper process no longer leaves the security audit worker orphaned. Fixes #77458. Thanks @jaikharbanda.
- Plugins/registry: recover managed-npm external plugins from the owned npm root when a stale persisted registry would otherwise hide them after package-manager upgrades. Fixes #77266. Thanks @p3nchan.
- fix(gateway): clamp unbound websocket auth scopes [AI]. (#77413) Thanks @pgondhi987.
- Gate zalouser startup name matching [AI]. (#77411) Thanks @pgondhi987.
- Active Memory: send a bounded latest-message search query to the recall worker so channel/runtime metadata does not become the memory search string. Fixes #65309. Thanks @joeykrug, @westley3601, @pimenov, and @tasi333.

View File

@@ -50,6 +50,61 @@ function writePackagePlugin(rootDir: string) {
);
}
function writeManagedNpmPlugin(params: {
stateDir: string;
packageName: string;
pluginId: string;
version: string;
dependencySpec?: string;
}): string {
const npmRoot = path.join(params.stateDir, "npm");
const rootManifestPath = path.join(npmRoot, "package.json");
fs.mkdirSync(npmRoot, { recursive: true });
const rootManifest = fs.existsSync(rootManifestPath)
? (JSON.parse(fs.readFileSync(rootManifestPath, "utf8")) as {
dependencies?: Record<string, string>;
})
: {};
fs.writeFileSync(
rootManifestPath,
JSON.stringify(
{
...rootManifest,
private: true,
dependencies: {
...rootManifest.dependencies,
[params.packageName]: params.dependencySpec ?? params.version,
},
},
null,
2,
),
"utf8",
);
const packageDir = path.join(npmRoot, "node_modules", params.packageName);
fs.mkdirSync(path.join(packageDir, "dist"), { recursive: true });
fs.writeFileSync(
path.join(packageDir, "package.json"),
JSON.stringify({
name: params.packageName,
version: params.version,
openclaw: { extensions: ["./dist/index.js"] },
}),
"utf8",
);
fs.writeFileSync(
path.join(packageDir, "openclaw.plugin.json"),
JSON.stringify({
id: params.pluginId,
configSchema: { type: "object" },
}),
"utf8",
);
fs.writeFileSync(path.join(packageDir, "dist", "index.js"), "export {};\n", "utf8");
return packageDir;
}
function replaceFilePreservingSizeAndMtime(filePath: string, contents: string) {
const previous = fs.statSync(filePath);
expect(Buffer.byteLength(contents)).toBe(previous.size);
@@ -72,6 +127,61 @@ function createManifestlessClaudeBundleIndex(params: {
}
describe("loadPluginRegistrySnapshotWithMetadata", () => {
it("recovers managed npm plugins missing from a stale persisted registry", () => {
const tempRoot = makeTempDir();
const stateDir = path.join(tempRoot, "state");
const env = {
...createHermeticEnv(tempRoot),
OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1",
OPENCLAW_STATE_DIR: stateDir,
};
const config = {};
const whatsappDir = writeManagedNpmPlugin({
stateDir,
packageName: "@openclaw/whatsapp",
pluginId: "whatsapp",
version: "2026.5.2",
});
const staleIndex = loadInstalledPluginIndex({
config,
env,
stateDir,
installRecords: {},
});
expect(staleIndex.plugins.some((plugin) => plugin.pluginId === "whatsapp")).toBe(false);
writePersistedInstalledPluginIndexSync(staleIndex, { stateDir });
const result = loadPluginRegistrySnapshotWithMetadata({
config,
env,
stateDir,
});
expect(result.source).toBe("derived");
expect(result.diagnostics).toContainEqual(
expect.objectContaining({ code: "persisted-registry-stale-source" }),
);
expect(result.snapshot.installRecords).toMatchObject({
whatsapp: {
source: "npm",
spec: "@openclaw/whatsapp@2026.5.2",
installPath: whatsappDir,
version: "2026.5.2",
resolvedName: "@openclaw/whatsapp",
resolvedVersion: "2026.5.2",
resolvedSpec: "@openclaw/whatsapp@2026.5.2",
},
});
expect(result.snapshot.plugins).toEqual(
expect.arrayContaining([
expect.objectContaining({
pluginId: "whatsapp",
origin: "global",
}),
]),
);
});
it("keeps persisted manifestless Claude bundles on the fast path", () => {
const tempRoot = makeTempDir();
const rootDir = path.join(tempRoot, "workspace");

View File

@@ -4,6 +4,7 @@ import path from "node:path";
import { resolveBundledPluginsDir } from "./bundled-dir.js";
import { fileSignatureMatches } from "./installed-plugin-index-hash.js";
import { hasOptionalMissingPluginManifestFile } from "./installed-plugin-index-manifest.js";
import { loadInstalledPluginIndexInstallRecordsSync } from "./installed-plugin-index-record-reader.js";
import {
inspectPersistedInstalledPluginIndex,
readPersistedInstalledPluginIndexSync,
@@ -167,6 +168,29 @@ function hasStalePersistedPluginMetadata(index: InstalledPluginIndex): boolean {
});
}
function loadSnapshotInstallRecords(params: LoadPluginRegistryParams, env: NodeJS.ProcessEnv) {
return loadInstalledPluginIndexInstallRecordsSync({
env,
...(params.stateDir ? { stateDir: params.stateDir } : {}),
...(params.filePath
? { filePath: params.filePath }
: params.pluginIndexFilePath
? { filePath: params.pluginIndexFilePath }
: {}),
});
}
function hasRecoveredInstallRecordsMissingFromPersistedIndex(
index: InstalledPluginIndex,
installRecords: ReturnType<typeof loadInstalledPluginIndexInstallRecordsSync>,
): boolean {
const persistedRecords = extractPluginInstallRecordsFromInstalledPluginIndex(index);
const persistedPluginIds = new Set(index.plugins.map((plugin) => plugin.pluginId));
return Object.keys(installRecords).some(
(pluginId) => !persistedRecords[pluginId] || !persistedPluginIds.has(pluginId),
);
}
export function loadPluginRegistrySnapshotWithMetadata(
params: LoadPluginRegistryParams = {},
): PluginRegistrySnapshotResult {
@@ -219,6 +243,18 @@ export function loadPluginRegistrySnapshotWithMetadata(
message:
"Persisted plugin registry metadata no longer matches plugin manifest or package files; using derived plugin index. Run `openclaw plugins registry --refresh` to update the persisted registry.",
});
} else if (
hasRecoveredInstallRecordsMissingFromPersistedIndex(
persistedIndex,
loadSnapshotInstallRecords(params, env),
)
) {
diagnostics.push({
level: "warn",
code: "persisted-registry-stale-source",
message:
"Persisted plugin registry is missing recoverable managed npm plugins; using derived plugin index. Run `openclaw plugins registry --refresh` to update the persisted registry.",
});
} else {
return {
snapshot: persistedIndex,
@@ -246,9 +282,9 @@ export function loadPluginRegistrySnapshotWithMetadata(
return {
snapshot: loadInstalledPluginIndex({
...params,
installRecords:
params.installRecords ??
extractPluginInstallRecordsFromInstalledPluginIndex(persistedIndex),
...(persistedInstallRecordReadsEnabled
? {}
: { installRecords: params.installRecords ?? {} }),
}),
source: "derived",
diagnostics,

View File

@@ -1,6 +1,8 @@
import fs from "node:fs";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { writePersistedInstalledPluginIndexSync } from "./installed-plugin-index-store.js";
import { loadInstalledPluginIndex } from "./installed-plugin-index.js";
import { refreshPluginRegistry } from "./plugin-registry.js";
import { buildPluginRegistrySnapshotReport, buildPluginSnapshotReport } from "./status.js";
import {
@@ -17,11 +19,120 @@ function makeTempDir() {
return makeTrackedTempDir("openclaw-plugin-status", tempDirs);
}
function writeManagedNpmPlugin(params: {
stateDir: string;
packageName: string;
pluginId: string;
version: string;
dependencySpec?: string;
}): string {
const npmRoot = path.join(params.stateDir, "npm");
const rootManifestPath = path.join(npmRoot, "package.json");
fs.mkdirSync(npmRoot, { recursive: true });
const rootManifest = fs.existsSync(rootManifestPath)
? (JSON.parse(fs.readFileSync(rootManifestPath, "utf8")) as {
dependencies?: Record<string, string>;
})
: {};
fs.writeFileSync(
rootManifestPath,
JSON.stringify(
{
...rootManifest,
private: true,
dependencies: {
...rootManifest.dependencies,
[params.packageName]: params.dependencySpec ?? params.version,
},
},
null,
2,
),
"utf8",
);
const packageDir = path.join(npmRoot, "node_modules", params.packageName);
fs.mkdirSync(path.join(packageDir, "dist"), { recursive: true });
fs.writeFileSync(
path.join(packageDir, "package.json"),
JSON.stringify({
name: params.packageName,
version: params.version,
openclaw: { extensions: ["./dist/index.js"] },
}),
"utf8",
);
fs.writeFileSync(
path.join(packageDir, "openclaw.plugin.json"),
JSON.stringify({
id: params.pluginId,
name: "WhatsApp",
configSchema: { type: "object" },
}),
"utf8",
);
fs.writeFileSync(path.join(packageDir, "dist", "index.js"), "export {};\n", "utf8");
return packageDir;
}
afterEach(() => {
cleanupTrackedTempDirs(tempDirs);
});
describe("buildPluginRegistrySnapshotReport", () => {
it("keeps recovered managed npm plugins visible when the persisted registry is stale", () => {
const tempRoot = makeTempDir();
const stateDir = path.join(tempRoot, "state");
const env = {
...createColdPluginHermeticEnv(tempRoot, {
bundledPluginsDir: makeTempDir(),
disablePersistedRegistry: false,
}),
OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1",
OPENCLAW_STATE_DIR: stateDir,
};
const config = {
plugins: {
entries: {
whatsapp: { enabled: true },
},
},
};
const whatsappDir = writeManagedNpmPlugin({
stateDir,
packageName: "@openclaw/whatsapp",
pluginId: "whatsapp",
version: "2026.5.2",
});
const staleIndex = loadInstalledPluginIndex({
config,
env,
installRecords: {},
});
expect(staleIndex.plugins.some((plugin) => plugin.pluginId === "whatsapp")).toBe(false);
writePersistedInstalledPluginIndexSync(staleIndex, { stateDir });
const report = buildPluginRegistrySnapshotReport({
config,
env,
});
expect(report.registrySource).toBe("derived");
expect(report.registryDiagnostics).toContainEqual(
expect.objectContaining({ code: "persisted-registry-stale-source" }),
);
expect(report.plugins).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: "whatsapp",
name: "WhatsApp",
source: fs.realpathSync(path.join(whatsappDir, "dist", "index.js")),
status: "loaded",
}),
]),
);
});
it("reconstructs list metadata from indexed manifests without importing plugin runtime", () => {
const fixture = createColdPluginFixture({
rootDir: makeTempDir(),