From 74a384d8876ccf487c13bf66da263ca74f353ade Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sat, 25 Apr 2026 01:08:15 -0700 Subject: [PATCH] feat(plugins): persist installed plugin index snapshots --- .../installed-plugin-index-store.test.ts | 140 ++++++++++++++++++ src/plugins/installed-plugin-index-store.ts | 118 +++++++++++++++ 2 files changed, 258 insertions(+) create mode 100644 src/plugins/installed-plugin-index-store.test.ts create mode 100644 src/plugins/installed-plugin-index-store.ts diff --git a/src/plugins/installed-plugin-index-store.test.ts b/src/plugins/installed-plugin-index-store.test.ts new file mode 100644 index 00000000000..62d61f56a1c --- /dev/null +++ b/src/plugins/installed-plugin-index-store.test.ts @@ -0,0 +1,140 @@ +import fs from "node:fs"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import type { PluginCandidate } from "./discovery.js"; +import { + readPersistedInstalledPluginIndex, + refreshPersistedInstalledPluginIndex, + resolveInstalledPluginIndexStorePath, + writePersistedInstalledPluginIndex, +} from "./installed-plugin-index-store.js"; +import type { InstalledPluginIndex } from "./installed-plugin-index.js"; +import { cleanupTrackedTempDirs, makeTrackedTempDir } from "./test-helpers/fs-fixtures.js"; + +const tempDirs: string[] = []; + +afterEach(() => { + cleanupTrackedTempDirs(tempDirs); +}); + +function makeTempDir() { + return makeTrackedTempDir("openclaw-installed-plugin-index-store", tempDirs); +} + +function createIndex(overrides: Partial = {}): InstalledPluginIndex { + return { + version: 1, + hostContractVersion: "2026.4.25", + compatRegistryVersion: "compat-v1", + generatedAt: "2026-04-25T12:00:00.000Z", + plugins: [ + { + pluginId: "demo", + manifestPath: "/plugins/demo/openclaw.plugin.json", + manifestHash: "manifest-hash", + rootDir: "/plugins/demo", + origin: "global", + enabled: true, + contributions: { + providers: ["demo"], + channels: ["demo-chat"], + channelConfigs: ["demo-chat"], + setupProviders: [], + cliBackends: [], + modelCatalogProviders: [], + commandAliases: [], + contracts: [], + }, + compat: [], + }, + ], + diagnostics: [], + ...overrides, + }; +} + +function createCandidate(rootDir: string): PluginCandidate { + fs.writeFileSync( + path.join(rootDir, "index.ts"), + "throw new Error('runtime entry should not load while persisting installed plugin index');\n", + "utf8", + ); + fs.writeFileSync( + path.join(rootDir, "openclaw.plugin.json"), + JSON.stringify({ + id: "demo", + name: "Demo", + configSchema: { type: "object" }, + providers: ["demo"], + }), + "utf8", + ); + return { + idHint: "demo", + source: path.join(rootDir, "index.ts"), + rootDir, + origin: "global", + }; +} + +describe("installed plugin index persistence", () => { + it("resolves the persisted index path under the state plugins directory", () => { + const stateDir = makeTempDir(); + + expect(resolveInstalledPluginIndexStorePath({ stateDir })).toBe( + path.join(stateDir, "plugins", "installed-index.json"), + ); + }); + + it("writes and reads the installed plugin index atomically", async () => { + const stateDir = makeTempDir(); + const filePath = resolveInstalledPluginIndexStorePath({ stateDir }); + const index = createIndex(); + + await expect(writePersistedInstalledPluginIndex(index, { stateDir })).resolves.toBe(filePath); + + expect(fs.readFileSync(filePath, "utf8")).toContain('"pluginId": "demo"'); + if (process.platform !== "win32") { + expect(fs.statSync(filePath).mode & 0o777).toBe(0o600); + } + await expect(readPersistedInstalledPluginIndex({ stateDir })).resolves.toEqual(index); + }); + + it("returns null for missing or invalid persisted indexes", async () => { + const stateDir = makeTempDir(); + await expect(readPersistedInstalledPluginIndex({ stateDir })).resolves.toBeNull(); + + const filePath = resolveInstalledPluginIndexStorePath({ stateDir }); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify({ version: 999 }), "utf8"); + + await expect(readPersistedInstalledPluginIndex({ stateDir })).resolves.toBeNull(); + }); + + it("refreshes and persists a rebuilt index without loading plugin runtime", async () => { + const stateDir = makeTempDir(); + const pluginDir = path.join(stateDir, "plugins", "demo"); + fs.mkdirSync(pluginDir, { recursive: true }); + const candidate = createCandidate(pluginDir); + + const index = await refreshPersistedInstalledPluginIndex({ + reason: "manual", + stateDir, + candidates: [candidate], + env: { + OPENCLAW_BUNDLED_PLUGINS_DIR: undefined, + OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE: "1", + OPENCLAW_DISABLE_PLUGIN_MANIFEST_CACHE: "1", + OPENCLAW_VERSION: "2026.4.25", + VITEST: "true", + }, + }); + + expect(index.refreshReason).toBe("manual"); + expect(index.plugins.map((plugin) => plugin.pluginId)).toEqual(["demo"]); + await expect(readPersistedInstalledPluginIndex({ stateDir })).resolves.toMatchObject({ + refreshReason: "manual", + plugins: [expect.objectContaining({ pluginId: "demo" })], + }); + }); +}); diff --git a/src/plugins/installed-plugin-index-store.ts b/src/plugins/installed-plugin-index-store.ts new file mode 100644 index 00000000000..5d73f76d236 --- /dev/null +++ b/src/plugins/installed-plugin-index-store.ts @@ -0,0 +1,118 @@ +import path from "node:path"; +import { z } from "zod"; +import { resolveStateDir } from "../config/paths.js"; +import { readJsonFile, writeJsonAtomic } from "../infra/json-files.js"; +import { safeParseWithSchema } from "../utils/zod-parse.js"; +import { + INSTALLED_PLUGIN_INDEX_VERSION, + refreshInstalledPluginIndex, + type InstalledPluginIndex, + type RefreshInstalledPluginIndexParams, +} from "./installed-plugin-index.js"; + +const INSTALLED_PLUGIN_INDEX_STORE_PATH = path.join("plugins", "installed-index.json"); + +export type InstalledPluginIndexStoreOptions = { + env?: NodeJS.ProcessEnv; + stateDir?: string; + filePath?: string; +}; + +const ContributionArraySchema = z.array(z.string()); + +const InstalledPluginIndexContributionsSchema = z + .object({ + providers: ContributionArraySchema, + channels: ContributionArraySchema, + channelConfigs: ContributionArraySchema, + setupProviders: ContributionArraySchema, + cliBackends: ContributionArraySchema, + modelCatalogProviders: ContributionArraySchema, + commandAliases: ContributionArraySchema, + contracts: ContributionArraySchema, + }) + .passthrough(); + +const InstalledPluginIndexRecordSchema = z + .object({ + pluginId: z.string(), + packageName: z.string().optional(), + packageVersion: z.string().optional(), + installRecord: z.record(z.string(), z.unknown()).optional(), + installRecordHash: z.string().optional(), + packageInstall: z.unknown().optional(), + manifestPath: z.string(), + manifestHash: z.string(), + packageJsonPath: z.string().optional(), + packageJsonHash: z.string().optional(), + rootDir: z.string(), + origin: z.string(), + enabled: z.boolean(), + contributions: InstalledPluginIndexContributionsSchema, + compat: z.array(z.string()), + }) + .passthrough(); + +const PluginDiagnosticSchema = z + .object({ + level: z.union([z.literal("warn"), z.literal("error")]), + message: z.string(), + pluginId: z.string().optional(), + source: z.string().optional(), + }) + .passthrough(); + +const InstalledPluginIndexSchema = z + .object({ + version: z.literal(INSTALLED_PLUGIN_INDEX_VERSION), + hostContractVersion: z.string(), + compatRegistryVersion: z.string(), + generatedAt: z.string(), + refreshReason: z.string().optional(), + plugins: z.array(InstalledPluginIndexRecordSchema), + diagnostics: z.array(PluginDiagnosticSchema), + }) + .passthrough(); + +function parseInstalledPluginIndex(value: unknown): InstalledPluginIndex | null { + return safeParseWithSchema(InstalledPluginIndexSchema, value) as InstalledPluginIndex | null; +} + +export function resolveInstalledPluginIndexStorePath( + options: InstalledPluginIndexStoreOptions = {}, +): string { + if (options.filePath) { + return options.filePath; + } + const env = options.env ?? process.env; + const stateDir = options.stateDir ?? resolveStateDir(env); + return path.join(stateDir, INSTALLED_PLUGIN_INDEX_STORE_PATH); +} + +export async function readPersistedInstalledPluginIndex( + options: InstalledPluginIndexStoreOptions = {}, +): Promise { + const parsed = await readJsonFile(resolveInstalledPluginIndexStorePath(options)); + return parseInstalledPluginIndex(parsed); +} + +export async function writePersistedInstalledPluginIndex( + index: InstalledPluginIndex, + options: InstalledPluginIndexStoreOptions = {}, +): Promise { + const filePath = resolveInstalledPluginIndexStorePath(options); + await writeJsonAtomic(filePath, index, { + trailingNewline: true, + ensureDirMode: 0o700, + mode: 0o600, + }); + return filePath; +} + +export async function refreshPersistedInstalledPluginIndex( + params: RefreshInstalledPluginIndexParams & InstalledPluginIndexStoreOptions, +): Promise { + const index = refreshInstalledPluginIndex(params); + await writePersistedInstalledPluginIndex(index, params); + return index; +}