mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:30:42 +00:00
fix(plugins): recover managed-npm external plugins after package-manager upgrade
Co-authored-by: pingu <pingu@penchan.co>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user