fix: preserve plugin install records without manifests

This commit is contained in:
Shakker
2026-04-26 00:44:09 +01:00
parent 1848d0dd38
commit babbad81a9
15 changed files with 285 additions and 37 deletions

View File

@@ -204,6 +204,7 @@ Docs: https://docs.openclaw.ai
a parent `$HOME/node_modules` tree. Fixes #71730.
- Plugins/install: pass onboarding plugin config into plugin index writes so local plugin installs outside default discovery roots keep their install records. Thanks @shakkernerd.
- Plugins/install: migrate shipped `plugins.installs` config records into the plugin index while stripping them from runtime config and future writes. Thanks @shakkernerd.
- Plugins/install: keep migrated plugin install records in the plugin index even when the plugin manifest is missing or invalid, so update, uninstall, inspect, and audit can still recover broken installs. Thanks @shakkernerd.
- Plugins/security: keep plugin audit JSON check ids stable while reporting plugin index install-record findings with updated wording. Thanks @shakkernerd.
- CLI/config: reject direct `plugins.installs` edits with guidance to use `openclaw plugins install`, `openclaw plugins update`, or `openclaw plugins uninstall` instead. Thanks @shakkernerd.
- Live tests/voice: accept common STT variants for OpenClaw and ElevenLabs

View File

@@ -238,8 +238,11 @@ the managed plugin index while keeping the default behavior unpinned.
Plugin install metadata is machine-managed state, not user config. Installs
and updates write it to `plugins/installs.json` under the active OpenClaw state
directory. The file includes a do-not-edit warning and is used by
`openclaw plugins update`, uninstall, diagnostics, and the cold plugin registry.
directory. Its top-level `installRecords` map is the durable source of install
metadata, including records for broken or missing plugin manifests. The
`plugins` array is the manifest-derived cold registry cache. The file includes a
do-not-edit warning and is used by `openclaw plugins update`, uninstall,
diagnostics, and the cold plugin registry.
### Uninstall

View File

@@ -921,6 +921,8 @@ paths into long-lived config. This keeps local development installs visible to
source-plane diagnostics without adding a second raw filesystem-path disclosure
surface. The persisted `plugins/installs.json` plugin index is the install
source of truth and can be refreshed without loading plugin runtime modules.
Its `installRecords` map is durable even when a plugin manifest is missing or
invalid; its `plugins` array is a rebuildable manifest/cache view.
## Context engine plugins

View File

@@ -305,8 +305,10 @@ immediately loadable after restart.
OpenClaw keeps a persisted local plugin registry as the cold read model for
plugin inventory, contribution ownership, and startup planning. Install, update,
uninstall, enable, and disable flows refresh that registry after changing plugin
state. If the registry is missing, stale, or invalid, `openclaw plugins registry
--refresh` rebuilds it from the durable plugin index, config policy, and
state. The same `plugins/installs.json` file keeps durable install metadata in
top-level `installRecords` and rebuildable manifest metadata in `plugins`. If
the registry is missing, stale, or invalid, `openclaw plugins registry
--refresh` rebuilds its manifest view from install records, config policy, and
manifest/package metadata without loading plugin runtime modules.
`openclaw plugins update <id-or-npm-spec>` applies to tracked installs. Passing
an npm package spec with a dist-tag or exact version resolves the package name

View File

@@ -70,6 +70,7 @@ function createCurrentIndex(): InstalledPluginIndex {
migrationVersion: 1,
policyHash: "policy-v1",
generatedAtMs: 1777118400000,
installRecords: {},
plugins: [],
diagnostics: [],
};

View File

@@ -75,6 +75,7 @@ function createCurrentIndex(): InstalledPluginIndex {
migrationVersion: 1,
policyHash: "policy-v1",
generatedAtMs: 1777118400000,
installRecords: {},
plugins: [],
diagnostics: [],
};
@@ -268,33 +269,91 @@ describe("plugin registry install migration", () => {
).resolves.toMatchObject({
status: "migrated",
current: {
installRecords: {
demo: {
source: "npm",
spec: "demo@1.0.0",
installPath: pluginDir,
},
},
plugins: [
expect.objectContaining({
pluginId: "demo",
installRecord: {
source: "npm",
spec: "demo@1.0.0",
installPath: pluginDir,
},
installRecordHash: expect.stringMatching(/^[a-f0-9]{64}$/u),
}),
],
},
});
await expect(readPersistedInstalledPluginIndex({ stateDir })).resolves.toMatchObject({
installRecords: {
demo: {
source: "npm",
spec: "demo@1.0.0",
installPath: pluginDir,
},
},
plugins: [
expect.objectContaining({
pluginId: "demo",
installRecord: {
source: "npm",
spec: "demo@1.0.0",
installPath: pluginDir,
},
installRecordHash: expect.stringMatching(/^[a-f0-9]{64}$/u),
}),
],
});
});
it("preserves shipped install records when the plugin manifest cannot be discovered", async () => {
const stateDir = makeTempDir();
const pluginDir = path.join(stateDir, "plugins", "missing");
await expect(
migratePluginRegistryForInstall({
stateDir,
candidates: [],
readConfig: async () => ({
plugins: {
entries: {
missing: {
enabled: true,
},
},
installs: {
missing: {
source: "npm",
spec: "missing-plugin@1.0.0",
installPath: pluginDir,
},
},
},
}),
env: hermeticEnv(),
}),
).resolves.toMatchObject({
status: "migrated",
current: {
installRecords: {
missing: {
source: "npm",
spec: "missing-plugin@1.0.0",
installPath: pluginDir,
},
},
plugins: [],
},
});
await expect(readPersistedInstalledPluginIndex({ stateDir })).resolves.toMatchObject({
installRecords: {
missing: {
source: "npm",
spec: "missing-plugin@1.0.0",
installPath: pluginDir,
},
},
plugins: [],
});
});
it("marks force migration env as deprecated break-glass", () => {
expect(
preflightPluginRegistryInstallMigration({

View File

@@ -165,14 +165,17 @@ describe("config io write", () => {
stateDir: path.join(home, ".openclaw"),
}),
).resolves.toMatchObject({
installRecords: {
demo: {
source: "npm",
spec: "demo@1.0.0",
installPath: pluginDir,
},
},
plugins: [
expect.objectContaining({
pluginId: "demo",
installRecord: {
source: "npm",
spec: "demo@1.0.0",
installPath: pluginDir,
},
installRecordHash: expect.stringMatching(/^[a-f0-9]{64}$/u),
}),
],
});
@@ -185,6 +188,53 @@ describe("config io write", () => {
});
});
it("migrates shipped plugin install config records even when the manifest is missing", async () => {
await withSuiteHome(async (home) => {
const configPath = path.join(home, ".openclaw", "openclaw.json");
const pluginDir = path.join(home, ".openclaw", "plugins", "missing");
await fs.mkdir(path.dirname(configPath), { recursive: true });
await fs.writeFile(
configPath,
`${JSON.stringify(
{
plugins: {
entries: { missing: { enabled: true } },
installs: {
missing: {
source: "npm",
spec: "missing-plugin@1.0.0",
installPath: pluginDir,
},
},
},
},
null,
2,
)}\n`,
"utf-8",
);
const io = createFastConfigIO(home);
const cfg = io.loadConfig();
expect(cfg.plugins?.installs).toBeUndefined();
await expect(
readPersistedInstalledPluginIndex({
stateDir: path.join(home, ".openclaw"),
}),
).resolves.toMatchObject({
installRecords: {
missing: {
source: "npm",
spec: "missing-plugin@1.0.0",
installPath: pluginDir,
},
},
plugins: [],
});
});
});
it("keeps shipped plugin install config records when index migration fails", async () => {
await withSuiteHome(async (home) => {
const configPath = path.join(home, ".openclaw", "openclaw.json");

View File

@@ -15,12 +15,30 @@ function cloneInstallRecords(
return structuredClone(records ?? {});
}
function readRecordMap(value: unknown): Record<string, PluginInstallRecord> | null {
if (!isRecord(value)) {
return null;
}
const records: Record<string, PluginInstallRecord> = {};
for (const [pluginId, record] of Object.entries(value).toSorted(([left], [right]) =>
left.localeCompare(right),
)) {
if (isRecord(record) && typeof record.source === "string") {
records[pluginId] = structuredClone(record) as PluginInstallRecord;
}
}
return records;
}
export function extractPluginInstallRecordsFromPersistedInstalledPluginIndex(
index: unknown,
): Record<string, PluginInstallRecord> | null {
if (!isRecord(index) || !Array.isArray(index.plugins)) {
return null;
}
if (Object.prototype.hasOwnProperty.call(index, "installRecords")) {
return readRecordMap(index.installRecords) ?? {};
}
const records: Record<string, PluginInstallRecord> = {};
for (const entry of index.plugins) {
if (!isRecord(entry) || typeof entry.pluginId !== "string" || !isRecord(entry.installRecord)) {

View File

@@ -75,14 +75,17 @@ describe("plugin index install records store", () => {
expect(JSON.parse(fs.readFileSync(indexPath, "utf8"))).toMatchObject({
version: 1,
generatedAtMs: 1777118400000,
installRecords: {
twitch: {
source: "npm",
spec: "@openclaw/plugin-twitch@1.0.0",
installPath: "plugins/npm/@openclaw/plugin-twitch",
},
},
plugins: [
{
pluginId: "twitch",
installRecord: {
source: "npm",
spec: "@openclaw/plugin-twitch@1.0.0",
installPath: "plugins/npm/@openclaw/plugin-twitch",
},
installRecordHash: expect.stringMatching(/^[a-f0-9]{64}$/u),
},
],
});
@@ -95,6 +98,47 @@ describe("plugin index install records store", () => {
});
});
it("preserves install records for plugins without a discovered manifest", async () => {
const stateDir = makeStateDir();
await writePersistedInstalledPluginIndexInstallRecords(
{
missing: {
source: "npm",
spec: "missing-plugin@1.0.0",
installPath: path.join(stateDir, "plugins", "missing"),
},
},
{
stateDir,
candidates: [],
now: () => new Date(1777118400000),
},
);
expect(
JSON.parse(
fs.readFileSync(resolveInstalledPluginIndexRecordsStorePath({ stateDir }), "utf8"),
),
).toMatchObject({
installRecords: {
missing: {
source: "npm",
spec: "missing-plugin@1.0.0",
installPath: path.join(stateDir, "plugins", "missing"),
},
},
plugins: [],
});
await expect(loadInstalledPluginIndexInstallRecords({ stateDir })).resolves.toEqual({
missing: {
source: "npm",
spec: "missing-plugin@1.0.0",
installPath: path.join(stateDir, "plugins", "missing"),
},
});
});
it("reads persisted records from the plugin index", async () => {
const stateDir = makeStateDir();
const candidate = createPluginCandidate(stateDir, "persisted");

View File

@@ -30,6 +30,7 @@ function createIndex(overrides: Partial<InstalledPluginIndex> = {}): InstalledPl
migrationVersion: 1,
policyHash: "policy-v1",
generatedAtMs: 1777118400000,
installRecords: {},
plugins: [
{
pluginId: "demo",

View File

@@ -84,6 +84,8 @@ const InstalledPluginIndexRecordSchema = z
})
.passthrough();
const InstalledPluginInstallRecordSchema = z.record(z.string(), z.unknown());
const PluginDiagnosticSchema = z
.object({
level: z.union([z.literal("warn"), z.literal("error")]),
@@ -103,13 +105,27 @@ const InstalledPluginIndexSchema = z
policyHash: z.string(),
generatedAtMs: z.number(),
refreshReason: z.string().optional(),
installRecords: z.record(z.string(), InstalledPluginInstallRecordSchema).optional(),
plugins: z.array(InstalledPluginIndexRecordSchema),
diagnostics: z.array(PluginDiagnosticSchema),
})
.passthrough();
function parseInstalledPluginIndex(value: unknown): InstalledPluginIndex | null {
return safeParseWithSchema(InstalledPluginIndexSchema, value) as InstalledPluginIndex | null;
const parsed = safeParseWithSchema(InstalledPluginIndexSchema, value) as
| (Omit<InstalledPluginIndex, "installRecords"> & {
installRecords?: InstalledPluginIndex["installRecords"];
})
| null;
if (!parsed) {
return null;
}
return {
...parsed,
installRecords:
parsed.installRecords ??
extractPluginInstallRecordsFromInstalledPluginIndex(parsed as InstalledPluginIndex),
};
}
export async function readPersistedInstalledPluginIndex(

View File

@@ -372,8 +372,8 @@ describe("installed plugin index", () => {
env: hermeticEnv(),
});
expect(index.plugins[0]).toMatchObject({
installRecord: {
expect(index.installRecords).toMatchObject({
demo: {
source: "npm",
spec: "@vendor/demo-plugin@latest",
installPath: "plugins/demo",
@@ -385,6 +385,8 @@ describe("installed plugin index", () => {
resolvedAt: "2026-04-25T11:00:00.000Z",
installedAt: "2026-04-25T11:01:00.000Z",
},
});
expect(index.plugins[0]).toMatchObject({
packageInstall: {
npm: {
spec: "@vendor/demo-plugin@1.2.3",
@@ -393,6 +395,7 @@ describe("installed plugin index", () => {
},
},
});
expect(index.plugins[0]?.installRecord).toBeUndefined();
expect(index.plugins[0]?.installRecordHash).toMatch(/^[a-f0-9]{64}$/u);
});
@@ -425,7 +428,10 @@ describe("installed plugin index", () => {
expect(index.plugins[0]).toMatchObject({
pluginId: "demo",
installRecord: {
installRecordHash: expect.stringMatching(/^[a-f0-9]{64}$/u),
});
expect(index.installRecords).toMatchObject({
demo: {
source: "npm",
spec: "@vendor/demo-plugin@latest",
installPath: fixture.rootDir,
@@ -467,7 +473,10 @@ describe("installed plugin index", () => {
expect(index.plugins[0]).toMatchObject({
pluginId: "demo",
installRecord: {
installRecordHash: expect.stringMatching(/^[a-f0-9]{64}$/u),
});
expect(index.installRecords).toMatchObject({
demo: {
source: "npm",
spec: "@vendor/demo-plugin@1.2.3",
installPath: fixture.rootDir,
@@ -500,7 +509,10 @@ describe("installed plugin index", () => {
expect(index.plugins[0]).toMatchObject({
pluginId: "demo",
installRecord: {
installRecordHash: expect.stringMatching(/^[a-f0-9]{64}$/u),
});
expect(index.installRecords).toMatchObject({
demo: {
source: "path",
sourcePath: "./plugins/demo",
spec: "@vendor/demo-plugin@1.2.3",

View File

@@ -83,10 +83,11 @@ export type InstalledPluginIndexRecord = {
packageName?: string;
packageVersion?: string;
/**
* Actual install record recorded by OpenClaw in the persisted plugin index.
* Legacy embedded install record accepted when reading earlier index files.
* New index writes keep install records in InstalledPluginIndex.installRecords.
*/
installRecord?: InstalledPluginInstallRecordInfo;
/** Hash of installRecord; used to detect source-changed invalidation. */
/** Hash of the top-level installRecords entry; used to detect source-changed invalidation. */
installRecordHash?: string;
/**
* Package-authored openclaw.install metadata. This describes catalog/package
@@ -117,6 +118,7 @@ export type InstalledPluginIndex = {
policyHash: string;
generatedAtMs: number;
refreshReason?: InstalledPluginIndexRefreshReason;
installRecords: Readonly<Record<string, InstalledPluginInstallRecordInfo>>;
plugins: readonly InstalledPluginIndexRecord[];
diagnostics: readonly PluginDiagnostic[];
};
@@ -384,9 +386,42 @@ function restoreInstallRecord(
return structuredClone(record) as PluginInstallRecord;
}
function normalizeInstallRecordMap(
records: Record<string, PluginInstallRecord> | undefined,
): Record<string, InstalledPluginInstallRecordInfo> {
const normalized: Record<string, InstalledPluginInstallRecordInfo> = {};
for (const [pluginId, record] of Object.entries(records ?? {}).toSorted(([left], [right]) =>
left.localeCompare(right),
)) {
const installRecord = normalizeInstallRecord(record);
if (installRecord) {
normalized[pluginId] = installRecord;
}
}
return normalized;
}
function restoreInstallRecordMap(
records: Readonly<Record<string, InstalledPluginInstallRecordInfo>> | undefined,
): Record<string, PluginInstallRecord> {
const restored: Record<string, PluginInstallRecord> = {};
for (const [pluginId, record] of Object.entries(records ?? {}).toSorted(([left], [right]) =>
left.localeCompare(right),
)) {
const installRecord = restoreInstallRecord(record);
if (installRecord) {
restored[pluginId] = installRecord;
}
}
return restored;
}
export function extractPluginInstallRecordsFromInstalledPluginIndex(
index: InstalledPluginIndex | null | undefined,
): Record<string, PluginInstallRecord> {
if (index && Object.prototype.hasOwnProperty.call(index, "installRecords")) {
return restoreInstallRecordMap(index.installRecords);
}
const records: Record<string, PluginInstallRecord> = {};
for (const plugin of index?.plugins ?? []) {
const record = restoreInstallRecord(plugin.installRecord);
@@ -501,11 +536,11 @@ function buildInstalledPluginIndex(
const normalizedConfig = normalizePluginsConfig(params.config?.plugins);
const diagnostics: PluginDiagnostic[] = [...registry.diagnostics];
const generatedAtMs = (params.now?.() ?? new Date()).getTime();
const installRecords = structuredClone(params.installRecords ?? {});
const installRecords = normalizeInstallRecordMap(params.installRecords);
const plugins = registry.plugins.map((record): InstalledPluginIndexRecord => {
const candidate = candidateByRootDir.get(record.rootDir);
const packageJsonPath = resolvePackageJsonPath(candidate);
const installRecord = normalizeInstallRecord(installRecords[record.id]);
const installRecord = installRecords[record.id];
const packageInstall = describePackageInstallSource(candidate);
const manifestHash =
safeHashFile({
@@ -548,7 +583,6 @@ function buildInstalledPluginIndex(
indexRecord.packageVersion = candidate.packageVersion;
}
if (installRecord) {
indexRecord.installRecord = installRecord;
indexRecord.installRecordHash = hashJson(installRecord);
}
if (packageInstall) {
@@ -569,6 +603,7 @@ function buildInstalledPluginIndex(
policyHash: resolveInstalledPluginIndexPolicyHash(params.config),
generatedAtMs,
...(params.refreshReason ? { refreshReason: params.refreshReason } : {}),
installRecords,
plugins,
diagnostics,
};
@@ -773,6 +808,9 @@ export function diffInstalledPluginIndexInvalidationReasons(
if (previous.policyHash !== current.policyHash) {
reasons.add("policy-changed");
}
if (hashJson(previous.installRecords ?? {}) !== hashJson(current.installRecords ?? {})) {
reasons.add("source-changed");
}
const previousByPluginId = new Map(previous.plugins.map((plugin) => [plugin.pluginId, plugin]));
const currentByPluginId = new Map(current.plugins.map((plugin) => [plugin.pluginId, plugin]));

View File

@@ -105,6 +105,7 @@ function createIndex(
migrationVersion: 1,
policyHash: "policy-v1",
generatedAtMs: 1777118400000,
installRecords: {},
plugins: [
{
pluginId,

View File

@@ -114,9 +114,9 @@ describe("security audit install metadata findings", () => {
migrationVersion: 1,
policyHash: "policy",
generatedAtMs: Date.now(),
plugins: Object.entries(records).map(([pluginId, installRecord]) => ({
installRecords: records,
plugins: Object.keys(records).map((pluginId) => ({
pluginId,
installRecord,
manifestPath: path.join(stateDir, "extensions", pluginId, "openclaw.plugin.json"),
manifestHash: "manifest",
rootDir: path.join(stateDir, "extensions", pluginId),