mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 08:10:44 +00:00
fix(plugins): reject stale registry policy reads
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user