From f56bf63b0602bfe985a2d5dd09bca7fe318f5fc0 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sat, 25 Apr 2026 10:30:05 -0700 Subject: [PATCH] fix(plugins): reject stale registry policy reads --- CHANGELOG.md | 1 + src/infra/json-files.ts | 2 +- .../installed-plugin-index-store.test.ts | 6 ++- src/plugins/installed-plugin-index-store.ts | 16 ++++-- src/plugins/installed-plugin-index.ts | 8 ++- src/plugins/plugin-registry.test.ts | 51 +++++++++++++++++-- src/plugins/plugin-registry.ts | 37 ++++++++++---- 7 files changed, 97 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fc99a0401f8..37a7733e6a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ Docs: https://docs.openclaw.ai - Providers/plugins: resolve provider ownership, provider discovery scopes, and catalog-hook provider ids from the cold plugin registry instead of rescanning manifests on those paths. Thanks @vincentkoc. - Plugins/chat commands: refresh the persisted plugin registry after `/plugins enable` and `/plugins disable`, matching the CLI mutation path. Thanks @vincentkoc. - Plugins/compat: mark `OPENCLAW_DISABLE_PERSISTED_PLUGIN_REGISTRY` as a deprecated break-glass switch and point operators at registry repair instead. Thanks @vincentkoc. +- Plugins/registry: ignore stale persisted registry reads when plugin policy no longer matches current config, and stamp generated registry files with a do-not-edit warning. Thanks @vincentkoc. - Diagnostics/OTEL: add bounded outbound message delivery lifecycle diagnostics and export them as low-cardinality delivery spans/metrics without message body, recipient, room, or media-path data. (#71471) Thanks @vincentkoc and @jlapenna. - Diagnostics/OTEL: emit bounded exec-process diagnostics and export them as `openclaw.exec` spans without exposing command text, working directories, or container identifiers. (#71451) Thanks @vincentkoc and @jlapenna. - Diagnostics/OTEL: support `OPENCLAW_OTEL_PRELOADED=1` so the plugin can reuse an already-registered OpenTelemetry SDK while keeping OpenClaw diagnostic listeners wired. (#71450) Thanks @vincentkoc and @jlapenna. diff --git a/src/infra/json-files.ts b/src/infra/json-files.ts index 14084a74f5a..137249ed1f0 100644 --- a/src/infra/json-files.ts +++ b/src/infra/json-files.ts @@ -39,7 +39,7 @@ export async function readJsonFile(filePath: string): Promise { export function readJsonFileSync(filePath: string): unknown { try { const raw = readFileSync(filePath, "utf8"); - return JSON.parse(raw) as T; + return JSON.parse(raw) as unknown; } catch { return null; } diff --git a/src/plugins/installed-plugin-index-store.test.ts b/src/plugins/installed-plugin-index-store.test.ts index 69c3541b2c4..28b9a107a4e 100644 --- a/src/plugins/installed-plugin-index-store.test.ts +++ b/src/plugins/installed-plugin-index-store.test.ts @@ -102,11 +102,13 @@ describe("installed plugin index persistence", () => { await expect(writePersistedInstalledPluginIndex(index, { stateDir })).resolves.toBe(filePath); - expect(fs.readFileSync(filePath, "utf8")).toContain('"pluginId": "demo"'); + const raw = fs.readFileSync(filePath, "utf8"); + expect(raw).toContain('"warning": "DO NOT EDIT.'); + expect(raw).toContain('"pluginId": "demo"'); if (process.platform !== "win32") { expect(fs.statSync(filePath).mode & 0o777).toBe(0o600); } - await expect(readPersistedInstalledPluginIndex({ stateDir })).resolves.toEqual(index); + await expect(readPersistedInstalledPluginIndex({ stateDir })).resolves.toMatchObject(index); }); it("returns null for missing or invalid persisted indexes", async () => { diff --git a/src/plugins/installed-plugin-index-store.ts b/src/plugins/installed-plugin-index-store.ts index cfdb53d3e36..9fea8c2dc32 100644 --- a/src/plugins/installed-plugin-index-store.ts +++ b/src/plugins/installed-plugin-index-store.ts @@ -5,6 +5,7 @@ import { readJsonFile, readJsonFileSync, writeJsonAtomic } from "../infra/json-f import { safeParseWithSchema } from "../utils/zod-parse.js"; import { diffInstalledPluginIndexInvalidationReasons, + INSTALLED_PLUGIN_INDEX_WARNING, INSTALLED_PLUGIN_INDEX_VERSION, INSTALLED_PLUGIN_INDEX_MIGRATION_VERSION, loadInstalledPluginIndex, @@ -94,6 +95,7 @@ const PluginDiagnosticSchema = z const InstalledPluginIndexSchema = z .object({ version: z.literal(INSTALLED_PLUGIN_INDEX_VERSION), + warning: z.string().optional(), hostContractVersion: z.string(), compatRegistryVersion: z.string(), migrationVersion: z.literal(INSTALLED_PLUGIN_INDEX_MIGRATION_VERSION), @@ -139,11 +141,15 @@ export async function writePersistedInstalledPluginIndex( options: InstalledPluginIndexStoreOptions = {}, ): Promise { const filePath = resolveInstalledPluginIndexStorePath(options); - await writeJsonAtomic(filePath, index, { - trailingNewline: true, - ensureDirMode: 0o700, - mode: 0o600, - }); + await writeJsonAtomic( + filePath, + { ...index, warning: INSTALLED_PLUGIN_INDEX_WARNING }, + { + trailingNewline: true, + ensureDirMode: 0o700, + mode: 0o600, + }, + ); return filePath; } diff --git a/src/plugins/installed-plugin-index.ts b/src/plugins/installed-plugin-index.ts index 817562375d2..e0e9ba5f85f 100644 --- a/src/plugins/installed-plugin-index.ts +++ b/src/plugins/installed-plugin-index.ts @@ -22,6 +22,8 @@ import { hasKind } from "./slots.js"; export const INSTALLED_PLUGIN_INDEX_VERSION = 1; export const INSTALLED_PLUGIN_INDEX_MIGRATION_VERSION = 2; +export const INSTALLED_PLUGIN_INDEX_WARNING = + "DO NOT EDIT. This file is generated by OpenClaw from plugin install/config state. Use `openclaw plugins registry --refresh`, `openclaw plugins install/update/uninstall`, or `openclaw plugins enable/disable` instead."; export type InstalledPluginIndexRefreshReason = | "missing" @@ -109,6 +111,7 @@ export type InstalledPluginIndexRecord = { export type InstalledPluginIndex = { version: typeof INSTALLED_PLUGIN_INDEX_VERSION; + warning?: string; hostContractVersion: string; compatRegistryVersion: string; migrationVersion: typeof INSTALLED_PLUGIN_INDEX_MIGRATION_VERSION; @@ -386,7 +389,7 @@ function resolveCompatRegistryVersion(): string { ); } -function resolvePolicyHash(config: OpenClawConfig | undefined): string { +export function resolveInstalledPluginIndexPolicyHash(config: OpenClawConfig | undefined): string { const normalized = normalizePluginsConfig(config?.plugins); const channelPolicy: Record = {}; const channels = config?.channels; @@ -527,10 +530,11 @@ function buildInstalledPluginIndex( return { version: INSTALLED_PLUGIN_INDEX_VERSION, + warning: INSTALLED_PLUGIN_INDEX_WARNING, hostContractVersion: resolveCompatibilityHostVersion(env), compatRegistryVersion: resolveCompatRegistryVersion(), migrationVersion: INSTALLED_PLUGIN_INDEX_MIGRATION_VERSION, - policyHash: resolvePolicyHash(params.config), + policyHash: resolveInstalledPluginIndexPolicyHash(params.config), generatedAtMs, ...(params.refreshReason ? { refreshReason: params.refreshReason } : {}), plugins, diff --git a/src/plugins/plugin-registry.test.ts b/src/plugins/plugin-registry.test.ts index 4e3fb663fa9..b13612f802c 100644 --- a/src/plugins/plugin-registry.test.ts +++ b/src/plugins/plugin-registry.test.ts @@ -3,7 +3,10 @@ import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import type { PluginCandidate } from "./discovery.js"; import { writePersistedInstalledPluginIndex } from "./installed-plugin-index-store.js"; -import type { InstalledPluginIndex } from "./installed-plugin-index.js"; +import { + resolveInstalledPluginIndexPolicyHash, + type InstalledPluginIndex, +} from "./installed-plugin-index.js"; import { DISABLE_PERSISTED_PLUGIN_REGISTRY_ENV, createPluginRegistryIdNormalizer, @@ -91,7 +94,10 @@ function createCandidate(rootDir: string): PluginCandidate { }; } -function createIndex(pluginId = "demo"): InstalledPluginIndex { +function createIndex( + pluginId = "demo", + overrides: Partial = {}, +): InstalledPluginIndex { return { version: 1, hostContractVersion: "2026.4.25", @@ -127,6 +133,7 @@ function createIndex(pluginId = "demo"): InstalledPluginIndex { }, ], diagnostics: [], + ...overrides, }; } @@ -241,11 +248,18 @@ describe("plugin registry facade", () => { const stateDir = makeTempDir(); const rootDir = makeTempDir(); const candidate = createCandidate(rootDir); - await writePersistedInstalledPluginIndex(createIndex("persisted"), { stateDir }); + const config = {} as const; + await writePersistedInstalledPluginIndex( + createIndex("persisted", { + policyHash: resolveInstalledPluginIndexPolicyHash(config), + }), + { stateDir }, + ); const result = loadPluginRegistrySnapshotWithMetadata({ stateDir, candidates: [candidate], + config, env: hermeticEnv(), }); @@ -256,6 +270,37 @@ describe("plugin registry facade", () => { ]); }); + it("falls back to the derived registry when persisted policy is stale", async () => { + const stateDir = makeTempDir(); + const rootDir = makeTempDir(); + const candidate = createCandidate(rootDir); + await writePersistedInstalledPluginIndex( + createIndex("persisted", { + policyHash: resolveInstalledPluginIndexPolicyHash({ + plugins: { entries: { persisted: { enabled: true } } }, + }), + }), + { stateDir }, + ); + + const result = loadPluginRegistrySnapshotWithMetadata({ + stateDir, + candidates: [candidate], + config: { + plugins: { entries: { demo: { enabled: true } } }, + }, + env: hermeticEnv(), + }); + + expect(result.source).toBe("derived"); + expect(result.diagnostics).toEqual([ + expect.objectContaining({ code: "persisted-registry-stale-policy" }), + ]); + expect(listPluginRecords({ index: result.snapshot }).map((plugin) => plugin.pluginId)).toEqual([ + "demo", + ]); + }); + it("falls back to the derived registry when the persisted registry is missing", () => { const stateDir = makeTempDir(); const rootDir = makeTempDir(); diff --git a/src/plugins/plugin-registry.ts b/src/plugins/plugin-registry.ts index 9377ac02330..beb32f06c17 100644 --- a/src/plugins/plugin-registry.ts +++ b/src/plugins/plugin-registry.ts @@ -16,6 +16,7 @@ import { listInstalledPluginRecords, loadInstalledPluginIndex, resolveInstalledPluginContributionOwners, + resolveInstalledPluginIndexPolicyHash, type InstalledPluginContributionKey, type InstalledPluginIndex, type InstalledPluginIndexRecord, @@ -29,7 +30,8 @@ export type PluginRegistryInspection = InstalledPluginIndexStoreInspection; export type PluginRegistrySnapshotSource = "provided" | "persisted" | "derived"; export type PluginRegistrySnapshotDiagnosticCode = | "persisted-registry-disabled" - | "persisted-registry-missing"; + | "persisted-registry-missing" + | "persisted-registry-stale-policy"; export type PluginRegistrySnapshotDiagnostic = { level: "info" | "warn"; @@ -163,17 +165,30 @@ export function loadPluginRegistrySnapshotWithMetadata( if (persistedReadsEnabled) { const persisted = readPersistedInstalledPluginIndexSync(params); if (persisted) { - return { - snapshot: persisted, - source: "persisted", - diagnostics, - }; + if ( + params.config && + persisted.policyHash !== resolveInstalledPluginIndexPolicyHash(params.config) + ) { + diagnostics.push({ + level: "warn", + code: "persisted-registry-stale-policy", + message: + "Persisted plugin registry policy does not match current config; using derived plugin index. Run `openclaw plugins registry --refresh` to update the persisted registry.", + }); + } else { + return { + snapshot: persisted, + source: "persisted", + diagnostics, + }; + } + } else { + diagnostics.push({ + level: "info", + code: "persisted-registry-missing", + message: "Persisted plugin registry is missing or invalid; using derived plugin index.", + }); } - diagnostics.push({ - level: "info", - code: "persisted-registry-missing", - message: "Persisted plugin registry is missing or invalid; using derived plugin index.", - }); } else { diagnostics.push({ level: "warn",