fix(plugins): reject stale registry policy reads

This commit is contained in:
Vincent Koc
2026-04-25 10:30:05 -07:00
parent 61b3c04424
commit f56bf63b06
7 changed files with 97 additions and 24 deletions

View File

@@ -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.

View File

@@ -39,7 +39,7 @@ export async function readJsonFile<T>(filePath: string): Promise<T | null> {
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;
}

View File

@@ -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 () => {

View File

@@ -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<string> {
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;
}

View File

@@ -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<string, boolean> = {};
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,

View File

@@ -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> = {},
): 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();

View File

@@ -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",