feat(plugins): persist installed plugin index snapshots

This commit is contained in:
Vincent Koc
2026-04-25 01:08:15 -07:00
parent dfac36ee01
commit 74a384d887
2 changed files with 258 additions and 0 deletions

View File

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

View File

@@ -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<InstalledPluginIndex | null> {
const parsed = await readJsonFile<unknown>(resolveInstalledPluginIndexStorePath(options));
return parseInstalledPluginIndex(parsed);
}
export async function writePersistedInstalledPluginIndex(
index: InstalledPluginIndex,
options: InstalledPluginIndexStoreOptions = {},
): Promise<string> {
const filePath = resolveInstalledPluginIndexStorePath(options);
await writeJsonAtomic(filePath, index, {
trailingNewline: true,
ensureDirMode: 0o700,
mode: 0o600,
});
return filePath;
}
export async function refreshPersistedInstalledPluginIndex(
params: RefreshInstalledPluginIndexParams & InstalledPluginIndexStoreOptions,
): Promise<InstalledPluginIndex> {
const index = refreshInstalledPluginIndex(params);
await writePersistedInstalledPluginIndex(index, params);
return index;
}