diff --git a/CHANGELOG.md b/CHANGELOG.md index 0121f790ab8..5c4179a1b5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/cli/plugins.md b/docs/cli/plugins.md index e324cf9c4d7..8e72120c207 100644 --- a/docs/cli/plugins.md +++ b/docs/cli/plugins.md @@ -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 diff --git a/docs/plugins/architecture-internals.md b/docs/plugins/architecture-internals.md index e3a13a76e8d..89b3fd9458e 100644 --- a/docs/plugins/architecture-internals.md +++ b/docs/plugins/architecture-internals.md @@ -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 diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index aba665f97cf..fb36bef7578 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -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 ` applies to tracked installs. Passing an npm package spec with a dist-tag or exact version resolves the package name diff --git a/src/commands/doctor-plugin-registry.test.ts b/src/commands/doctor-plugin-registry.test.ts index 43a801330c3..3822e92e522 100644 --- a/src/commands/doctor-plugin-registry.test.ts +++ b/src/commands/doctor-plugin-registry.test.ts @@ -70,6 +70,7 @@ function createCurrentIndex(): InstalledPluginIndex { migrationVersion: 1, policyHash: "policy-v1", generatedAtMs: 1777118400000, + installRecords: {}, plugins: [], diagnostics: [], }; diff --git a/src/commands/doctor/shared/plugin-registry-migration.test.ts b/src/commands/doctor/shared/plugin-registry-migration.test.ts index 309416c6486..7b2d5941889 100644 --- a/src/commands/doctor/shared/plugin-registry-migration.test.ts +++ b/src/commands/doctor/shared/plugin-registry-migration.test.ts @@ -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({ diff --git a/src/config/io.write-config.test.ts b/src/config/io.write-config.test.ts index 31963adf9af..7e4fcc02f93 100644 --- a/src/config/io.write-config.test.ts +++ b/src/config/io.write-config.test.ts @@ -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"); diff --git a/src/plugins/installed-plugin-index-record-reader.ts b/src/plugins/installed-plugin-index-record-reader.ts index 608d7d6d06c..ca6e3ddbfab 100644 --- a/src/plugins/installed-plugin-index-record-reader.ts +++ b/src/plugins/installed-plugin-index-record-reader.ts @@ -15,12 +15,30 @@ function cloneInstallRecords( return structuredClone(records ?? {}); } +function readRecordMap(value: unknown): Record | null { + if (!isRecord(value)) { + return null; + } + const records: Record = {}; + 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 | null { if (!isRecord(index) || !Array.isArray(index.plugins)) { return null; } + if (Object.prototype.hasOwnProperty.call(index, "installRecords")) { + return readRecordMap(index.installRecords) ?? {}; + } const records: Record = {}; for (const entry of index.plugins) { if (!isRecord(entry) || typeof entry.pluginId !== "string" || !isRecord(entry.installRecord)) { diff --git a/src/plugins/installed-plugin-index-records.test.ts b/src/plugins/installed-plugin-index-records.test.ts index a6ad85a1bea..598a3332c7b 100644 --- a/src/plugins/installed-plugin-index-records.test.ts +++ b/src/plugins/installed-plugin-index-records.test.ts @@ -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"); diff --git a/src/plugins/installed-plugin-index-store.test.ts b/src/plugins/installed-plugin-index-store.test.ts index 4fd62fc4e05..5686e0c1e0e 100644 --- a/src/plugins/installed-plugin-index-store.test.ts +++ b/src/plugins/installed-plugin-index-store.test.ts @@ -30,6 +30,7 @@ function createIndex(overrides: Partial = {}): InstalledPl migrationVersion: 1, policyHash: "policy-v1", generatedAtMs: 1777118400000, + installRecords: {}, plugins: [ { pluginId: "demo", diff --git a/src/plugins/installed-plugin-index-store.ts b/src/plugins/installed-plugin-index-store.ts index 4fb075767d2..199118827b8 100644 --- a/src/plugins/installed-plugin-index-store.ts +++ b/src/plugins/installed-plugin-index-store.ts @@ -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 & { + installRecords?: InstalledPluginIndex["installRecords"]; + }) + | null; + if (!parsed) { + return null; + } + return { + ...parsed, + installRecords: + parsed.installRecords ?? + extractPluginInstallRecordsFromInstalledPluginIndex(parsed as InstalledPluginIndex), + }; } export async function readPersistedInstalledPluginIndex( diff --git a/src/plugins/installed-plugin-index.test.ts b/src/plugins/installed-plugin-index.test.ts index 9518c3afc81..eb2bc35b0bd 100644 --- a/src/plugins/installed-plugin-index.test.ts +++ b/src/plugins/installed-plugin-index.test.ts @@ -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", diff --git a/src/plugins/installed-plugin-index.ts b/src/plugins/installed-plugin-index.ts index e9a5c12f238..262358c8f1d 100644 --- a/src/plugins/installed-plugin-index.ts +++ b/src/plugins/installed-plugin-index.ts @@ -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>; plugins: readonly InstalledPluginIndexRecord[]; diagnostics: readonly PluginDiagnostic[]; }; @@ -384,9 +386,42 @@ function restoreInstallRecord( return structuredClone(record) as PluginInstallRecord; } +function normalizeInstallRecordMap( + records: Record | undefined, +): Record { + const normalized: Record = {}; + 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> | undefined, +): Record { + const restored: Record = {}; + 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 { + if (index && Object.prototype.hasOwnProperty.call(index, "installRecords")) { + return restoreInstallRecordMap(index.installRecords); + } const records: Record = {}; 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])); diff --git a/src/plugins/plugin-registry.test.ts b/src/plugins/plugin-registry.test.ts index 0d9b57a5640..d962b6db94c 100644 --- a/src/plugins/plugin-registry.test.ts +++ b/src/plugins/plugin-registry.test.ts @@ -105,6 +105,7 @@ function createIndex( migrationVersion: 1, policyHash: "policy-v1", generatedAtMs: 1777118400000, + installRecords: {}, plugins: [ { pluginId, diff --git a/src/security/audit-plugins-trust.test.ts b/src/security/audit-plugins-trust.test.ts index 52aa3172039..61bdf5ea843 100644 --- a/src/security/audit-plugins-trust.test.ts +++ b/src/security/audit-plugins-trust.test.ts @@ -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),