fix(plugins): recover managed npm install ledger

This commit is contained in:
Peter Steinberger
2026-05-03 23:17:17 +01:00
parent 90a5b08fb7
commit 4047f4d0b4
3 changed files with 257 additions and 2 deletions

View File

@@ -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.<space>.enabled` key instead of the legacy `allow` alias, with a schema regression for the documented group shape. Thanks @vincentkoc.

View File

@@ -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<string, PluginInstallRecord> | nu
return records;
}
function readJsonObjectFileSync(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 readStringRecord(value: unknown): Record<string, string> {
if (!isRecord(value)) {
return {};
}
const record: Record<string, string> = {};
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<string, unknown>): 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<string, PluginInstallRecord> {
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<string, PluginInstallRecord> = {};
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<string, PluginInstallRecord> | null,
options: InstalledPluginIndexStoreOptions,
): Record<string, PluginInstallRecord> {
return {
...buildRecoveredManagedNpmInstallRecords(options),
...persisted,
};
}
function extractPluginInstallRecordsFromPersistedInstalledPluginIndex(
index: unknown,
): Record<string, PluginInstallRecord> | null {
@@ -66,11 +174,21 @@ export function readPersistedInstalledPluginIndexInstallRecordsSync(
export async function loadInstalledPluginIndexInstallRecords(
params: InstalledPluginIndexStoreOptions = {},
): Promise<Record<string, PluginInstallRecord>> {
return cloneInstallRecords((await readPersistedInstalledPluginIndexInstallRecords(params)) ?? {});
return cloneInstallRecords(
mergeRecoveredManagedNpmInstallRecords(
await readPersistedInstalledPluginIndexInstallRecords(params),
params,
),
);
}
export function loadInstalledPluginIndexInstallRecordsSync(
params: InstalledPluginIndexStoreOptions = {},
): Record<string, PluginInstallRecord> {
return cloneInstallRecords(readPersistedInstalledPluginIndexInstallRecordsSync(params) ?? {});
return cloneInstallRecords(
mergeRecoveredManagedNpmInstallRecords(
readPersistedInstalledPluginIndexInstallRecordsSync(params),
params,
),
);
}

View File

@@ -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<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;
}
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");