diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c6453204a9..4465b3ef515 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/plugins/plugin-registry-snapshot.test.ts b/src/plugins/plugin-registry-snapshot.test.ts index 5f3087f62f5..eb019ab9eaf 100644 --- a/src/plugins/plugin-registry-snapshot.test.ts +++ b/src/plugins/plugin-registry-snapshot.test.ts @@ -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; + }) + : {}; + 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"); diff --git a/src/plugins/plugin-registry-snapshot.ts b/src/plugins/plugin-registry-snapshot.ts index f918f23d244..c147fc89b7b 100644 --- a/src/plugins/plugin-registry-snapshot.ts +++ b/src/plugins/plugin-registry-snapshot.ts @@ -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, +): 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, diff --git a/src/plugins/status.registry-snapshot.test.ts b/src/plugins/status.registry-snapshot.test.ts index db8cb957bd7..35b9ee81baf 100644 --- a/src/plugins/status.registry-snapshot.test.ts +++ b/src/plugins/status.registry-snapshot.test.ts @@ -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; + }) + : {}; + 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(),