diff --git a/CHANGELOG.md b/CHANGELOG.md index c43873db812..9953fa4a21c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Google Meet: refresh realtime browser state during status and retry delayed speech after Meet finishes joining, so a just-opened in-call tab no longer leaves speech stuck behind stale `not-in-call` health. +- Plugins/install: recover the install ledger from the managed npm root when `plugins/installs.json` is empty or partial, so reinstalling Discord and Codex no longer makes the other installed plugin disappear. - Google Meet: grant Meet media permissions through the Playwright browser context when CDP grants do not affect the attached Chrome page, and report in-call microphone/speaker permission problems instead of marking realtime speech ready. - Tlon: expose `groupInviteAllowlist` in the channel config schema and clarify that group invite auto-accept fails closed without an invite allowlist. Thanks @vincentkoc. - Google Chat: update the setup example to use the accepted `groups..enabled` key instead of the legacy `allow` alias, with a schema regression for the documented group shape. Thanks @vincentkoc. diff --git a/src/plugins/installed-plugin-index-record-reader.ts b/src/plugins/installed-plugin-index-record-reader.ts index 58096ed8d4e..c2de72d7035 100644 --- a/src/plugins/installed-plugin-index-record-reader.ts +++ b/src/plugins/installed-plugin-index-record-reader.ts @@ -1,5 +1,8 @@ +import fs from "node:fs"; +import path from "node:path"; import type { PluginInstallRecord } from "../config/types.plugins.js"; import { readJsonFile, readJsonFileSync } from "../infra/json-files.js"; +import { resolveDefaultPluginNpmDir, validatePluginId } from "./install-paths.js"; import { resolveInstalledPluginIndexStorePath, type InstalledPluginIndexStoreOptions, @@ -30,6 +33,111 @@ function readRecordMap(value: unknown): Record | nu return records; } +function readJsonObjectFileSync(filePath: string): Record | null { + try { + const parsed = JSON.parse(fs.readFileSync(filePath, "utf8")) as unknown; + return isRecord(parsed) ? parsed : null; + } catch { + return null; + } +} + +function readStringRecord(value: unknown): Record { + if (!isRecord(value)) { + return {}; + } + const record: Record = {}; + for (const [key, raw] of Object.entries(value).toSorted(([left], [right]) => + left.localeCompare(right), + )) { + if (typeof raw === "string" && raw.trim()) { + record[key] = raw.trim(); + } + } + return record; +} + +function hasPackagePluginMetadata(manifest: Record): boolean { + const openclaw = manifest.openclaw; + if (!isRecord(openclaw)) { + return false; + } + const extensions = openclaw.extensions; + return Array.isArray(extensions) && extensions.some((entry) => typeof entry === "string"); +} + +function readManifestPluginId(packageDir: string): string | undefined { + const manifest = readJsonObjectFileSync(path.join(packageDir, "openclaw.plugin.json")); + const id = typeof manifest?.id === "string" ? manifest.id.trim() : ""; + return id || undefined; +} + +function resolveRecoveredManagedNpmPluginId(params: { + packageName: string; + packageDir: string; +}): string | undefined { + const packageManifest = readJsonObjectFileSync(path.join(params.packageDir, "package.json")); + if (!packageManifest || !hasPackagePluginMetadata(packageManifest)) { + return undefined; + } + const packageName = + typeof packageManifest.name === "string" && packageManifest.name.trim() + ? packageManifest.name.trim() + : params.packageName; + const pluginId = readManifestPluginId(params.packageDir) ?? packageName; + return validatePluginId(pluginId) ? undefined : pluginId; +} + +function buildRecoveredManagedNpmInstallRecords( + options: InstalledPluginIndexStoreOptions = {}, +): Record { + const npmRoot = options.stateDir + ? path.join(options.stateDir, "npm") + : resolveDefaultPluginNpmDir(options.env); + const rootManifest = readJsonObjectFileSync(path.join(npmRoot, "package.json")); + const dependencies = readStringRecord(rootManifest?.dependencies); + const records: Record = {}; + for (const [packageName, dependencySpec] of Object.entries(dependencies)) { + const packageDir = path.join(npmRoot, "node_modules", packageName); + let stat: fs.Stats; + try { + stat = fs.statSync(packageDir); + } catch { + continue; + } + if (!stat.isDirectory()) { + continue; + } + const pluginId = resolveRecoveredManagedNpmPluginId({ packageName, packageDir }); + if (!pluginId) { + continue; + } + const packageManifest = readJsonObjectFileSync(path.join(packageDir, "package.json")); + const version = + typeof packageManifest?.version === "string" && packageManifest.version.trim() + ? packageManifest.version.trim() + : undefined; + records[pluginId] = { + source: "npm", + spec: `${packageName}@${dependencySpec}`, + installPath: packageDir, + ...(version ? { version, resolvedName: packageName, resolvedVersion: version } : {}), + ...(version ? { resolvedSpec: `${packageName}@${version}` } : {}), + }; + } + return records; +} + +function mergeRecoveredManagedNpmInstallRecords( + persisted: Record | null, + options: InstalledPluginIndexStoreOptions, +): Record { + return { + ...buildRecoveredManagedNpmInstallRecords(options), + ...persisted, + }; +} + function extractPluginInstallRecordsFromPersistedInstalledPluginIndex( index: unknown, ): Record | null { @@ -66,11 +174,21 @@ export function readPersistedInstalledPluginIndexInstallRecordsSync( export async function loadInstalledPluginIndexInstallRecords( params: InstalledPluginIndexStoreOptions = {}, ): Promise> { - return cloneInstallRecords((await readPersistedInstalledPluginIndexInstallRecords(params)) ?? {}); + return cloneInstallRecords( + mergeRecoveredManagedNpmInstallRecords( + await readPersistedInstalledPluginIndexInstallRecords(params), + params, + ), + ); } export function loadInstalledPluginIndexInstallRecordsSync( params: InstalledPluginIndexStoreOptions = {}, ): Record { - return cloneInstallRecords(readPersistedInstalledPluginIndexInstallRecordsSync(params) ?? {}); + return cloneInstallRecords( + mergeRecoveredManagedNpmInstallRecords( + readPersistedInstalledPluginIndexInstallRecordsSync(params), + params, + ), + ); } diff --git a/src/plugins/installed-plugin-index-records.test.ts b/src/plugins/installed-plugin-index-records.test.ts index 44f87d9e06e..666d56b4657 100644 --- a/src/plugins/installed-plugin-index-records.test.ts +++ b/src/plugins/installed-plugin-index-records.test.ts @@ -44,6 +44,61 @@ function createPluginCandidate(stateDir: string, pluginId: string): PluginCandid }; } +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; +} + afterEach(() => { for (const dir of tempDirs.splice(0)) { fs.rmSync(dir, { recursive: true, force: true }); @@ -164,6 +219,87 @@ describe("plugin index install records store", () => { }); }); + it("recovers managed npm plugin records when the persisted ledger is empty", async () => { + const stateDir = makeStateDir(); + const discordDir = writeManagedNpmPlugin({ + stateDir, + packageName: "@openclaw/discord", + pluginId: "discord", + version: "2026.5.2", + }); + const codexDir = writeManagedNpmPlugin({ + stateDir, + packageName: "@openclaw/codex", + pluginId: "codex", + version: "2026.5.2", + }); + const indexPath = resolveInstalledPluginIndexRecordsStorePath({ stateDir }); + fs.mkdirSync(path.dirname(indexPath), { recursive: true }); + fs.writeFileSync(indexPath, JSON.stringify({ installRecords: {}, plugins: [] }), "utf8"); + + await expect(loadInstalledPluginIndexInstallRecords({ stateDir })).resolves.toMatchObject({ + codex: { + source: "npm", + spec: "@openclaw/codex@2026.5.2", + installPath: codexDir, + version: "2026.5.2", + resolvedName: "@openclaw/codex", + resolvedVersion: "2026.5.2", + resolvedSpec: "@openclaw/codex@2026.5.2", + }, + discord: { + source: "npm", + spec: "@openclaw/discord@2026.5.2", + installPath: discordDir, + version: "2026.5.2", + resolvedName: "@openclaw/discord", + resolvedVersion: "2026.5.2", + resolvedSpec: "@openclaw/discord@2026.5.2", + }, + }); + expect(loadInstalledPluginIndexInstallRecordsSync({ stateDir })).toMatchObject({ + codex: { + source: "npm", + installPath: codexDir, + }, + discord: { + source: "npm", + installPath: discordDir, + }, + }); + }); + + it("keeps persisted install record metadata over recovered npm records", async () => { + const stateDir = makeStateDir(); + writeManagedNpmPlugin({ + stateDir, + packageName: "@openclaw/discord", + pluginId: "discord", + version: "2026.5.2", + }); + const candidate = createPluginCandidate(stateDir, "discord"); + await writePersistedInstalledPluginIndexInstallRecords( + { + discord: { + source: "npm", + spec: "@openclaw/discord@beta", + installPath: path.join(stateDir, "custom", "discord"), + integrity: "sha512-persisted", + }, + }, + { stateDir, candidates: [candidate] }, + ); + + await expect(loadInstalledPluginIndexInstallRecords({ stateDir })).resolves.toMatchObject({ + discord: { + source: "npm", + spec: "@openclaw/discord@beta", + installPath: path.join(stateDir, "custom", "discord"), + integrity: "sha512-persisted", + }, + }); + }); + it("preserves git install resolution fields in persisted records", async () => { const stateDir = makeStateDir(); const candidate = createPluginCandidate(stateDir, "git-demo");