diff --git a/src/infra/json-files.ts b/src/infra/json-files.ts index d1d87cdf8ad..68e56b38a5e 100644 --- a/src/infra/json-files.ts +++ b/src/infra/json-files.ts @@ -1,4 +1,5 @@ import { randomUUID } from "node:crypto"; +import { readFileSync } from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; @@ -35,6 +36,15 @@ export async function readJsonFile(filePath: string): Promise { } } +export function readJsonFileSync(filePath: string): T | null { + try { + const raw = readFileSync(filePath, "utf8"); + return JSON.parse(raw) as T; + } catch { + return null; + } +} + export async function writeJsonAtomic( filePath: string, value: unknown, diff --git a/src/plugins/installed-plugin-index-store.ts b/src/plugins/installed-plugin-index-store.ts index 0ae846971e5..ebc6f934cc1 100644 --- a/src/plugins/installed-plugin-index-store.ts +++ b/src/plugins/installed-plugin-index-store.ts @@ -1,7 +1,7 @@ import path from "node:path"; import { z } from "zod"; import { resolveStateDir } from "../config/paths.js"; -import { readJsonFile, writeJsonAtomic } from "../infra/json-files.js"; +import { readJsonFile, readJsonFileSync, writeJsonAtomic } from "../infra/json-files.js"; import { safeParseWithSchema } from "../utils/zod-parse.js"; import { diffInstalledPluginIndexInvalidationReasons, @@ -117,6 +117,13 @@ export async function readPersistedInstalledPluginIndex( return parseInstalledPluginIndex(parsed); } +export function readPersistedInstalledPluginIndexSync( + options: InstalledPluginIndexStoreOptions = {}, +): InstalledPluginIndex | null { + const parsed = readJsonFileSync(resolveInstalledPluginIndexStorePath(options)); + return parseInstalledPluginIndex(parsed); +} + export async function writePersistedInstalledPluginIndex( index: InstalledPluginIndex, options: InstalledPluginIndexStoreOptions = {}, diff --git a/src/plugins/plugin-registry.test.ts b/src/plugins/plugin-registry.test.ts index ebdabb20578..fd3a53b47ef 100644 --- a/src/plugins/plugin-registry.test.ts +++ b/src/plugins/plugin-registry.test.ts @@ -2,13 +2,17 @@ import fs from "node:fs"; 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 { + DISABLE_PERSISTED_PLUGIN_REGISTRY_ENV, getPluginRecord, inspectPluginRegistry, isPluginEnabled, listPluginContributionIds, listPluginRecords, loadPluginRegistrySnapshot, + loadPluginRegistrySnapshotWithMetadata, refreshPluginRegistry, resolveChannelOwners, resolveCliBackendOwners, @@ -85,6 +89,39 @@ function createCandidate(rootDir: string): PluginCandidate { }; } +function createIndex(pluginId = "demo"): InstalledPluginIndex { + return { + version: 1, + hostContractVersion: "2026.4.25", + compatRegistryVersion: "compat-v1", + migrationVersion: 1, + policyHash: "policy-v1", + generatedAtMs: 1777118400000, + plugins: [ + { + pluginId, + manifestPath: `/plugins/${pluginId}/openclaw.plugin.json`, + manifestHash: "manifest-hash", + rootDir: `/plugins/${pluginId}`, + origin: "global", + enabled: true, + contributions: { + providers: [pluginId], + channels: [], + channelConfigs: [], + setupProviders: [], + cliBackends: [], + modelCatalogProviders: [], + commandAliases: [], + contracts: [], + }, + compat: [], + }, + ], + diagnostics: [], + }; +} + describe("plugin registry facade", () => { it("resolves cold plugin records and contribution owners without loading runtime", () => { const rootDir = makeTempDir(); @@ -92,6 +129,7 @@ describe("plugin registry facade", () => { const index = loadPluginRegistrySnapshot({ candidates: [candidate], env: hermeticEnv(), + preferPersisted: false, }); expect(listPluginRecords({ index }).map((plugin) => plugin.pluginId)).toEqual(["demo"]); @@ -129,6 +167,7 @@ describe("plugin registry facade", () => { }, }, env: hermeticEnv(), + preferPersisted: false, }); expect(getPluginRecord({ index, pluginId: "demo" })).toMatchObject({ @@ -151,6 +190,66 @@ describe("plugin registry facade", () => { ).toEqual(["demo"]); }); + it("reads the persisted registry before deriving from discovered candidates", async () => { + const stateDir = makeTempDir(); + const rootDir = makeTempDir(); + const candidate = createCandidate(rootDir); + await writePersistedInstalledPluginIndex(createIndex("persisted"), { stateDir }); + + const result = loadPluginRegistrySnapshotWithMetadata({ + stateDir, + candidates: [candidate], + env: hermeticEnv(), + }); + + expect(result.source).toBe("persisted"); + expect(result.diagnostics).toEqual([]); + expect(listPluginRecords({ index: result.snapshot }).map((plugin) => plugin.pluginId)).toEqual([ + "persisted", + ]); + }); + + it("falls back to the derived registry when the persisted registry is missing", () => { + const stateDir = makeTempDir(); + const rootDir = makeTempDir(); + const candidate = createCandidate(rootDir); + + const result = loadPluginRegistrySnapshotWithMetadata({ + stateDir, + candidates: [candidate], + env: hermeticEnv(), + }); + + expect(result.source).toBe("derived"); + expect(result.diagnostics).toEqual([ + expect.objectContaining({ code: "persisted-registry-missing" }), + ]); + expect(listPluginRecords({ index: result.snapshot }).map((plugin) => plugin.pluginId)).toEqual([ + "demo", + ]); + }); + + it("falls back to the derived registry when persisted reads are disabled", async () => { + const stateDir = makeTempDir(); + const rootDir = makeTempDir(); + const candidate = createCandidate(rootDir); + await writePersistedInstalledPluginIndex(createIndex("persisted"), { stateDir }); + + const result = loadPluginRegistrySnapshotWithMetadata({ + stateDir, + candidates: [candidate], + env: hermeticEnv({ [DISABLE_PERSISTED_PLUGIN_REGISTRY_ENV]: "1" }), + }); + + expect(result.source).toBe("derived"); + expect(result.diagnostics).toEqual([ + expect.objectContaining({ code: "persisted-registry-disabled" }), + ]); + expect(listPluginRecords({ index: result.snapshot }).map((plugin) => plugin.pluginId)).toEqual([ + "demo", + ]); + }); + it("exposes explicit persisted registry inspect and refresh operations", async () => { const stateDir = makeTempDir(); const pluginDir = path.join(stateDir, "plugins", "demo"); diff --git a/src/plugins/plugin-registry.ts b/src/plugins/plugin-registry.ts index 9ab3048c034..9dcdd28a4f4 100644 --- a/src/plugins/plugin-registry.ts +++ b/src/plugins/plugin-registry.ts @@ -1,7 +1,8 @@ import { normalizeProviderId } from "../agents/provider-id.js"; -import type { - InstalledPluginIndexStoreInspection, - InstalledPluginIndexStoreOptions, +import { + readPersistedInstalledPluginIndexSync, + type InstalledPluginIndexStoreInspection, + type InstalledPluginIndexStoreOptions, } from "./installed-plugin-index-store.js"; import { getInstalledPluginRecord, @@ -20,11 +21,31 @@ import { export type PluginRegistrySnapshot = InstalledPluginIndex; export type PluginRegistryRecord = InstalledPluginIndexRecord; export type PluginRegistryInspection = InstalledPluginIndexStoreInspection; +export type PluginRegistrySnapshotSource = "provided" | "persisted" | "derived"; +export type PluginRegistrySnapshotDiagnosticCode = + | "persisted-registry-disabled" + | "persisted-registry-missing"; -export type LoadPluginRegistryParams = LoadInstalledPluginIndexParams & { - index?: PluginRegistrySnapshot; +export type PluginRegistrySnapshotDiagnostic = { + level: "info" | "warn"; + code: PluginRegistrySnapshotDiagnosticCode; + message: string; }; +export type PluginRegistrySnapshotResult = { + snapshot: PluginRegistrySnapshot; + source: PluginRegistrySnapshotSource; + diagnostics: readonly PluginRegistrySnapshotDiagnostic[]; +}; + +export const DISABLE_PERSISTED_PLUGIN_REGISTRY_ENV = "OPENCLAW_DISABLE_PERSISTED_PLUGIN_REGISTRY"; + +export type LoadPluginRegistryParams = LoadInstalledPluginIndexParams & + InstalledPluginIndexStoreOptions & { + index?: PluginRegistrySnapshot; + preferPersisted?: boolean; + }; + export type PluginRegistryContributionOptions = LoadPluginRegistryParams & { includeDisabled?: boolean; }; @@ -62,8 +83,60 @@ function normalizeContributionId(value: string): string { return value.trim(); } +function hasEnvFlag(env: NodeJS.ProcessEnv, name: string): boolean { + const value = env[name]?.trim().toLowerCase(); + return Boolean(value && value !== "0" && value !== "false" && value !== "no"); +} + +export function loadPluginRegistrySnapshotWithMetadata( + params: LoadPluginRegistryParams = {}, +): PluginRegistrySnapshotResult { + if (params.index) { + return { + snapshot: params.index, + source: "provided", + diagnostics: [], + }; + } + + const env = params.env ?? process.env; + const diagnostics: PluginRegistrySnapshotDiagnostic[] = []; + const disabledByCaller = params.preferPersisted === false; + const disabledByEnv = hasEnvFlag(env, DISABLE_PERSISTED_PLUGIN_REGISTRY_ENV); + const persistedReadsEnabled = !disabledByCaller && !disabledByEnv; + if (persistedReadsEnabled) { + const persisted = readPersistedInstalledPluginIndexSync(params); + if (persisted) { + return { + snapshot: persisted, + source: "persisted", + diagnostics, + }; + } + 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", + code: "persisted-registry-disabled", + message: disabledByEnv + ? `${DISABLE_PERSISTED_PLUGIN_REGISTRY_ENV} is set; using legacy derived plugin index.` + : "Persisted plugin registry reads are disabled by the caller; using derived plugin index.", + }); + } + + return { + snapshot: loadInstalledPluginIndex(params), + source: "derived", + diagnostics, + }; +} + function resolveSnapshot(params: LoadPluginRegistryParams = {}): PluginRegistrySnapshot { - return params.index ?? loadInstalledPluginIndex(params); + return loadPluginRegistrySnapshotWithMetadata(params).snapshot; } export function loadPluginRegistrySnapshot(