refactor: consolidate plugin install index store

This commit is contained in:
Shakker
2026-04-25 23:06:49 +01:00
parent f8123e4b68
commit c19f8a5223
35 changed files with 522 additions and 489 deletions

View File

@@ -1,162 +0,0 @@
import path from "node:path";
import { z } from "zod";
import { resolveStateDir } from "../config/paths.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { PluginInstallRecord } from "../config/types.plugins.js";
import { readJsonFile, readJsonFileSync, writeJsonAtomic } from "../infra/json-files.js";
import { safeParseWithSchema } from "../utils/zod-parse.js";
import { recordPluginInstall, type PluginInstallUpdate } from "./installs.js";
export const PLUGIN_INSTALL_LEDGER_VERSION = 1;
export const PLUGIN_INSTALL_LEDGER_STORE_PATH = path.join("plugins", "installs.json");
export const PLUGIN_INSTALL_LEDGER_WARNING =
"DO NOT EDIT. This file is generated by OpenClaw plugin install/update/uninstall commands. Use `openclaw plugins install/update/uninstall` instead.";
export const PLUGIN_INSTALLS_CONFIG_PATH = ["plugins", "installs"] as const;
export type PluginInstallLedger = {
version: typeof PLUGIN_INSTALL_LEDGER_VERSION;
warning?: string;
updatedAtMs: number;
records: Record<string, PluginInstallRecord>;
};
export type PluginInstallLedgerStoreOptions = {
env?: NodeJS.ProcessEnv;
stateDir?: string;
filePath?: string;
};
const PluginInstallRecordSchema = z
.object({
source: z.string(),
})
.passthrough();
const PluginInstallLedgerSchema = z
.object({
version: z.literal(PLUGIN_INSTALL_LEDGER_VERSION),
warning: z.string().optional(),
updatedAtMs: z.number(),
records: z.record(z.string(), PluginInstallRecordSchema),
})
.passthrough();
function parsePluginInstallLedger(value: unknown): PluginInstallLedger | null {
return safeParseWithSchema(PluginInstallLedgerSchema, value) as PluginInstallLedger | null;
}
function cloneInstallRecords(
records: Record<string, PluginInstallRecord> | undefined,
): Record<string, PluginInstallRecord> {
return structuredClone(records ?? {});
}
export function resolvePluginInstallLedgerStorePath(
options: PluginInstallLedgerStoreOptions = {},
): string {
if (options.filePath) {
return options.filePath;
}
const env = options.env ?? process.env;
const stateDir = options.stateDir ?? resolveStateDir(env);
return path.join(stateDir, PLUGIN_INSTALL_LEDGER_STORE_PATH);
}
export async function readPersistedPluginInstallLedger(
options: PluginInstallLedgerStoreOptions = {},
): Promise<PluginInstallLedger | null> {
const parsed = await readJsonFile<unknown>(resolvePluginInstallLedgerStorePath(options));
return parsePluginInstallLedger(parsed);
}
export function readPersistedPluginInstallLedgerSync(
options: PluginInstallLedgerStoreOptions = {},
): PluginInstallLedger | null {
const parsed = readJsonFileSync(resolvePluginInstallLedgerStorePath(options));
return parsePluginInstallLedger(parsed);
}
export async function writePersistedPluginInstallLedger(
records: Record<string, PluginInstallRecord>,
options: PluginInstallLedgerStoreOptions & { now?: () => Date } = {},
): Promise<string> {
const filePath = resolvePluginInstallLedgerStorePath(options);
await writeJsonAtomic(
filePath,
{
version: PLUGIN_INSTALL_LEDGER_VERSION,
warning: PLUGIN_INSTALL_LEDGER_WARNING,
updatedAtMs: (options.now ?? (() => new Date()))().getTime(),
records,
} satisfies PluginInstallLedger,
{
trailingNewline: true,
ensureDirMode: 0o700,
mode: 0o600,
},
);
return filePath;
}
export async function loadPluginInstallRecords(
params: PluginInstallLedgerStoreOptions & { config?: OpenClawConfig } = {},
): Promise<Record<string, PluginInstallRecord>> {
const ledger = await readPersistedPluginInstallLedger(params);
if (ledger) {
return cloneInstallRecords(ledger.records);
}
return cloneInstallRecords(params.config?.plugins?.installs);
}
export function loadPluginInstallRecordsSync(
params: PluginInstallLedgerStoreOptions & { config?: OpenClawConfig } = {},
): Record<string, PluginInstallRecord> {
const ledger = readPersistedPluginInstallLedgerSync(params);
if (ledger) {
return cloneInstallRecords(ledger.records);
}
return cloneInstallRecords(params.config?.plugins?.installs);
}
export function withPluginInstallRecords(
config: OpenClawConfig,
records: Record<string, PluginInstallRecord>,
): OpenClawConfig {
return {
...config,
plugins: {
...config.plugins,
installs: records,
},
};
}
export function withoutPluginInstallRecords(config: OpenClawConfig): OpenClawConfig {
if (!config.plugins?.installs) {
return config;
}
const { installs: _installs, ...plugins } = config.plugins;
if (Object.keys(plugins).length === 0) {
const { plugins: _plugins, ...rest } = config;
return rest;
}
return {
...config,
plugins,
};
}
export function recordPluginInstallInRecords(
records: Record<string, PluginInstallRecord>,
update: PluginInstallUpdate,
): Record<string, PluginInstallRecord> {
return recordPluginInstall({ plugins: { installs: records } }, update).plugins?.installs ?? {};
}
export function removePluginInstallRecordFromRecords(
records: Record<string, PluginInstallRecord>,
pluginId: string,
): Record<string, PluginInstallRecord> {
const { [pluginId]: _removed, ...rest } = records;
return rest;
}

View File

@@ -3,37 +3,59 @@ import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import type { PluginInstallRecord } from "../config/types.plugins.js";
import type { PluginCandidate } from "./discovery.js";
import {
loadPluginInstallRecords,
loadPluginInstallRecordsSync,
PLUGIN_INSTALL_LEDGER_WARNING,
readPersistedPluginInstallLedger,
loadInstalledPluginIndexInstallRecords,
loadInstalledPluginIndexInstallRecordsSync,
readPersistedInstalledPluginIndexInstallRecords,
recordPluginInstallInRecords,
removePluginInstallRecordFromRecords,
resolvePluginInstallLedgerStorePath,
resolveInstalledPluginIndexRecordsStorePath,
withoutPluginInstallRecords,
writePersistedPluginInstallLedger,
} from "./install-ledger-store.js";
writePersistedInstalledPluginIndexInstallRecords,
} from "./installed-plugin-index-records.js";
const tempDirs: string[] = [];
function makeStateDir(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-plugin-ledger-"));
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-plugin-index-records-"));
tempDirs.push(dir);
return dir;
}
function createPluginCandidate(stateDir: string, pluginId: string): PluginCandidate {
const rootDir = path.join(stateDir, "plugins", pluginId);
fs.mkdirSync(rootDir, { recursive: true });
const source = path.join(rootDir, "index.ts");
fs.writeFileSync(source, "export function register() {}\n", "utf8");
fs.writeFileSync(
path.join(rootDir, "openclaw.plugin.json"),
JSON.stringify({
id: pluginId,
configSchema: { type: "object" },
}),
"utf8",
);
return {
idHint: pluginId,
source,
rootDir,
origin: "global",
};
}
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
describe("plugin install ledger store", () => {
describe("plugin index install records store", () => {
it("writes machine-managed install records outside config", async () => {
const stateDir = makeStateDir();
const candidate = createPluginCandidate(stateDir, "twitch");
await writePersistedPluginInstallLedger(
await writePersistedInstalledPluginIndexInstallRecords(
{
twitch: {
source: "npm",
@@ -43,51 +65,52 @@ describe("plugin install ledger store", () => {
},
{
stateDir,
candidates: [candidate],
now: () => new Date(1777118400000),
},
);
const ledgerPath = resolvePluginInstallLedgerStorePath({ stateDir });
expect(ledgerPath).toBe(path.join(stateDir, "plugins", "installs.json"));
expect(JSON.parse(fs.readFileSync(ledgerPath, "utf8"))).toEqual({
const indexPath = resolveInstalledPluginIndexRecordsStorePath({ stateDir });
expect(indexPath).toBe(path.join(stateDir, "plugins", "installs.json"));
expect(JSON.parse(fs.readFileSync(indexPath, "utf8"))).toMatchObject({
version: 1,
warning: PLUGIN_INSTALL_LEDGER_WARNING,
updatedAtMs: 1777118400000,
records: {
twitch: {
source: "npm",
spec: "@openclaw/plugin-twitch@1.0.0",
installPath: "plugins/npm/@openclaw/plugin-twitch",
generatedAtMs: 1777118400000,
plugins: [
{
pluginId: "twitch",
installRecord: {
source: "npm",
spec: "@openclaw/plugin-twitch@1.0.0",
installPath: "plugins/npm/@openclaw/plugin-twitch",
},
},
],
});
await expect(readPersistedInstalledPluginIndexInstallRecords({ stateDir })).resolves.toEqual({
twitch: {
source: "npm",
spec: "@openclaw/plugin-twitch@1.0.0",
installPath: "plugins/npm/@openclaw/plugin-twitch",
},
});
});
it("prefers persisted records over legacy config installs", async () => {
it("reads persisted records from the plugin index", async () => {
const stateDir = makeStateDir();
await writePersistedPluginInstallLedger(
const candidate = createPluginCandidate(stateDir, "persisted");
await writePersistedInstalledPluginIndexInstallRecords(
{
persisted: {
source: "npm",
spec: "persisted@1.0.0",
},
},
{ stateDir },
{ stateDir, candidates: [candidate] },
);
await expect(
loadPluginInstallRecords({
loadInstalledPluginIndexInstallRecords({
stateDir,
config: {
plugins: {
installs: {
legacy: {
source: "npm",
spec: "legacy@1.0.0",
},
},
},
},
}),
).resolves.toEqual({
persisted: {
@@ -97,29 +120,14 @@ describe("plugin install ledger store", () => {
});
});
it("falls back to legacy config installs when no ledger exists", () => {
it("returns an empty record map when no plugin index exists", () => {
const stateDir = makeStateDir();
expect(
loadPluginInstallRecordsSync({
loadInstalledPluginIndexInstallRecordsSync({
stateDir,
config: {
plugins: {
installs: {
legacy: {
source: "path",
sourcePath: "./plugins/legacy",
},
},
},
},
}),
).toEqual({
legacy: {
source: "path",
sourcePath: "./plugins/legacy",
},
});
).toEqual({});
});
it("updates and removes records without mutating caller state", async () => {
@@ -150,7 +158,7 @@ describe("plugin install ledger store", () => {
expect(removePluginInstallRecordFromRecords(withInstall, "demo")).toEqual(records);
});
it("strips legacy installs from config writes", () => {
it("strips transient install records from config writes", () => {
expect(
withoutPluginInstallRecords({
plugins: {
@@ -171,28 +179,19 @@ describe("plugin install ledger store", () => {
});
});
it("ignores invalid persisted ledgers and falls back to config", async () => {
it("ignores invalid persisted plugin index files", async () => {
const stateDir = makeStateDir();
fs.mkdirSync(path.join(stateDir, "plugins"), { recursive: true });
fs.writeFileSync(
resolvePluginInstallLedgerStorePath({ stateDir }),
resolveInstalledPluginIndexRecordsStorePath({ stateDir }),
JSON.stringify({ version: 999, records: {} }),
);
await expect(readPersistedPluginInstallLedger({ stateDir })).resolves.toBeNull();
await expect(readPersistedInstalledPluginIndexInstallRecords({ stateDir })).resolves.toBeNull();
await expect(
loadPluginInstallRecords({
loadInstalledPluginIndexInstallRecords({
stateDir,
config: {
plugins: {
installs: {
legacy: { source: "npm", spec: "legacy@1.0.0" },
},
},
},
}),
).resolves.toEqual({
legacy: { source: "npm", spec: "legacy@1.0.0" },
});
).resolves.toEqual({});
});
});

View File

@@ -0,0 +1,127 @@
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { PluginInstallRecord } from "../config/types.plugins.js";
import {
readPersistedInstalledPluginIndex,
readPersistedInstalledPluginIndexSync,
refreshPersistedInstalledPluginIndex,
resolveInstalledPluginIndexStorePath,
type InstalledPluginIndexStoreOptions,
} from "./installed-plugin-index-store.js";
import {
extractPluginInstallRecordsFromInstalledPluginIndex,
type RefreshInstalledPluginIndexParams,
} from "./installed-plugin-index.js";
import { recordPluginInstall, type PluginInstallUpdate } from "./installs.js";
export const PLUGIN_INSTALLS_CONFIG_PATH = ["plugins", "installs"] as const;
export type InstalledPluginIndexRecordStoreOptions = {
env?: NodeJS.ProcessEnv;
stateDir?: string;
filePath?: string;
};
type InstalledPluginIndexRecordRefreshOptions = InstalledPluginIndexRecordStoreOptions &
Partial<Omit<RefreshInstalledPluginIndexParams, "reason" | "installRecords">> & {
now?: () => Date;
};
function toInstallRecords(
index: Awaited<ReturnType<typeof readPersistedInstalledPluginIndex>>,
): Record<string, PluginInstallRecord> | null {
if (!index) {
return null;
}
return extractPluginInstallRecordsFromInstalledPluginIndex(index);
}
function cloneInstallRecords(
records: Record<string, PluginInstallRecord> | undefined,
): Record<string, PluginInstallRecord> {
return structuredClone(records ?? {});
}
export function resolveInstalledPluginIndexRecordsStorePath(
options: InstalledPluginIndexRecordStoreOptions = {},
): string {
return resolveInstalledPluginIndexStorePath(options);
}
export async function readPersistedInstalledPluginIndexInstallRecords(
options: InstalledPluginIndexRecordStoreOptions = {},
): Promise<Record<string, PluginInstallRecord> | null> {
return toInstallRecords(await readPersistedInstalledPluginIndex(options));
}
export function readPersistedInstalledPluginIndexInstallRecordsSync(
options: InstalledPluginIndexRecordStoreOptions = {},
): Record<string, PluginInstallRecord> | null {
return toInstallRecords(readPersistedInstalledPluginIndexSync(options));
}
export async function writePersistedInstalledPluginIndexInstallRecords(
records: Record<string, PluginInstallRecord>,
options: InstalledPluginIndexRecordRefreshOptions = {},
): Promise<string> {
await refreshPersistedInstalledPluginIndex({
...options,
reason: "source-changed",
installRecords: records,
});
return resolveInstalledPluginIndexRecordsStorePath(options);
}
export async function loadInstalledPluginIndexInstallRecords(
params: InstalledPluginIndexRecordStoreOptions = {},
): Promise<Record<string, PluginInstallRecord>> {
return cloneInstallRecords((await readPersistedInstalledPluginIndexInstallRecords(params)) ?? {});
}
export function loadInstalledPluginIndexInstallRecordsSync(
params: InstalledPluginIndexRecordStoreOptions = {},
): Record<string, PluginInstallRecord> {
return cloneInstallRecords(readPersistedInstalledPluginIndexInstallRecordsSync(params) ?? {});
}
export function withPluginInstallRecords(
config: OpenClawConfig,
records: Record<string, PluginInstallRecord>,
): OpenClawConfig {
return {
...config,
plugins: {
...config.plugins,
installs: records,
},
};
}
export function withoutPluginInstallRecords(config: OpenClawConfig): OpenClawConfig {
if (!config.plugins?.installs) {
return config;
}
const { installs: _installs, ...plugins } = config.plugins;
if (Object.keys(plugins).length === 0) {
const { plugins: _plugins, ...rest } = config;
return rest;
}
return {
...config,
plugins,
};
}
export function recordPluginInstallInRecords(
records: Record<string, PluginInstallRecord>,
update: PluginInstallUpdate,
): Record<string, PluginInstallRecord> {
return recordPluginInstall({ plugins: { installs: records } }, update).plugins?.installs ?? {};
}
export function removePluginInstallRecordFromRecords(
records: Record<string, PluginInstallRecord>,
pluginId: string,
): Record<string, PluginInstallRecord> {
const { [pluginId]: _removed, ...rest } = records;
return rest;
}

View File

@@ -27,7 +27,7 @@ function createIndex(overrides: Partial<InstalledPluginIndex> = {}): InstalledPl
version: 1,
hostContractVersion: "2026.4.25",
compatRegistryVersion: "compat-v1",
migrationVersion: 2,
migrationVersion: 1,
policyHash: "policy-v1",
generatedAtMs: 1777118400000,
plugins: [
@@ -91,7 +91,7 @@ describe("installed plugin index persistence", () => {
const stateDir = makeTempDir();
expect(resolveInstalledPluginIndexStorePath({ stateDir })).toBe(
path.join(stateDir, "plugins", "installed-index.json"),
path.join(stateDir, "plugins", "installs.json"),
);
});

View File

@@ -5,6 +5,7 @@ import { readJsonFile, readJsonFileSync, writeJsonAtomic } from "../infra/json-f
import { safeParseWithSchema } from "../utils/zod-parse.js";
import {
diffInstalledPluginIndexInvalidationReasons,
extractPluginInstallRecordsFromInstalledPluginIndex,
INSTALLED_PLUGIN_INDEX_WARNING,
INSTALLED_PLUGIN_INDEX_VERSION,
INSTALLED_PLUGIN_INDEX_MIGRATION_VERSION,
@@ -16,7 +17,7 @@ import {
type RefreshInstalledPluginIndexParams,
} from "./installed-plugin-index.js";
export const INSTALLED_PLUGIN_INDEX_STORE_PATH = path.join("plugins", "installed-index.json");
export const INSTALLED_PLUGIN_INDEX_STORE_PATH = path.join("plugins", "installs.json");
export type InstalledPluginIndexStoreOptions = {
env?: NodeJS.ProcessEnv;
@@ -157,7 +158,11 @@ export async function inspectPersistedInstalledPluginIndex(
params: LoadInstalledPluginIndexParams & InstalledPluginIndexStoreOptions = {},
): Promise<InstalledPluginIndexStoreInspection> {
const persisted = await readPersistedInstalledPluginIndex(params);
const current = loadInstalledPluginIndex(params);
const current = loadInstalledPluginIndex({
...params,
installRecords:
params.installRecords ?? extractPluginInstallRecordsFromInstalledPluginIndex(persisted),
});
if (!persisted) {
return {
state: "missing",

View File

@@ -2,7 +2,10 @@ import fs from "node:fs";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { PluginCandidate } from "./discovery.js";
import { writePersistedPluginInstallLedger } from "./install-ledger-store.js";
import {
loadInstalledPluginIndexInstallRecordsSync,
writePersistedInstalledPluginIndexInstallRecords,
} from "./installed-plugin-index-records.js";
import {
diffInstalledPluginIndexInvalidationReasons,
getInstalledPluginRecord,
@@ -156,7 +159,7 @@ describe("installed plugin index", () => {
expect(index).toMatchObject({
version: 1,
migrationVersion: 2,
migrationVersion: 1,
generatedAtMs: 1777118400000,
plugins: [
{
@@ -239,7 +242,7 @@ describe("installed plugin index", () => {
});
});
it("exposes cold registry records and owners for existing plugins without install ledgers", () => {
it("exposes cold registry records and owners for existing plugins without plugin indexs", () => {
const fixture = createRichPluginFixture();
const index = loadInstalledPluginIndex({
candidates: [fixture.candidate],
@@ -347,27 +350,23 @@ describe("installed plugin index", () => {
expect(listEnabledInstalledPluginRecords(index, config)).toEqual([]);
});
it("records the config install ledger separately from package install intent", () => {
it("records explicit install records separately from package install intent", () => {
const fixture = createRichPluginFixture();
const index = loadInstalledPluginIndex({
candidates: [fixture.candidate],
config: {
plugins: {
installs: {
demo: {
source: "npm",
spec: "@vendor/demo-plugin@latest",
installPath: "plugins/demo",
resolvedName: "@vendor/demo-plugin",
resolvedVersion: "1.2.3",
resolvedSpec: "@vendor/demo-plugin@1.2.3",
integrity: "sha512-installed",
shasum: "abc123",
resolvedAt: "2026-04-25T11:00:00.000Z",
installedAt: "2026-04-25T11:01:00.000Z",
},
},
installRecords: {
demo: {
source: "npm",
spec: "@vendor/demo-plugin@latest",
installPath: "plugins/demo",
resolvedName: "@vendor/demo-plugin",
resolvedVersion: "1.2.3",
resolvedSpec: "@vendor/demo-plugin@1.2.3",
integrity: "sha512-installed",
shasum: "abc123",
resolvedAt: "2026-04-25T11:00:00.000Z",
installedAt: "2026-04-25T11:01:00.000Z",
},
},
env: hermeticEnv(),
@@ -397,7 +396,7 @@ describe("installed plugin index", () => {
expect(index.plugins[0]?.installRecordHash).toMatch(/^[a-f0-9]{64}$/u);
});
it("indexes npm install ledger records written before a process reload", () => {
it("indexes npm plugin index records written before a process reload", () => {
const fixture = createRichPluginFixture();
const cfg = recordPluginInstall(
{},
@@ -420,6 +419,7 @@ describe("installed plugin index", () => {
const index = loadInstalledPluginIndex({
candidates: [fixture.candidate],
config: cfg,
installRecords: cfg.plugins?.installs,
env: hermeticEnv(),
});
@@ -441,10 +441,10 @@ describe("installed plugin index", () => {
});
});
it("indexes persisted install ledger records from an explicit state directory", async () => {
it("indexes persisted plugin index records from an explicit state directory", async () => {
const fixture = createRichPluginFixture();
const stateDir = makeTempDir();
await writePersistedPluginInstallLedger(
await writePersistedInstalledPluginIndexInstallRecords(
{
demo: {
source: "npm",
@@ -455,13 +455,14 @@ describe("installed plugin index", () => {
integrity: "sha512-installed",
},
},
{ stateDir },
{ stateDir, candidates: [fixture.candidate] },
);
const index = loadInstalledPluginIndex({
candidates: [fixture.candidate],
env: hermeticEnv(),
stateDir,
installRecords: loadInstalledPluginIndexInstallRecordsSync({ stateDir }),
});
expect(index.plugins[0]).toMatchObject({
@@ -477,7 +478,7 @@ describe("installed plugin index", () => {
});
});
it("indexes local fallback install ledger records written before a process reload", () => {
it("indexes local fallback plugin index records written before a process reload", () => {
const fixture = createRichPluginFixture();
const cfg = recordPluginInstall(
{},
@@ -493,6 +494,7 @@ describe("installed plugin index", () => {
const index = loadInstalledPluginIndex({
candidates: [fixture.candidate],
config: cfg,
installRecords: cfg.plugins?.installs,
env: hermeticEnv(),
});
@@ -511,17 +513,13 @@ describe("installed plugin index", () => {
const fixture = createRichPluginFixture();
const previous = loadInstalledPluginIndex({
candidates: [fixture.candidate],
config: {
plugins: {
installs: {
demo: {
source: "npm",
resolvedName: "@vendor/demo-plugin",
resolvedVersion: "1.2.3",
resolvedSpec: "@vendor/demo-plugin@1.2.3",
integrity: "sha512-installed",
},
},
installRecords: {
demo: {
source: "npm",
resolvedName: "@vendor/demo-plugin",
resolvedVersion: "1.2.3",
resolvedSpec: "@vendor/demo-plugin@1.2.3",
integrity: "sha512-installed",
},
},
env: hermeticEnv(),
@@ -540,38 +538,30 @@ describe("installed plugin index", () => {
expect(diffInstalledPluginIndexInvalidationReasons(previous, current)).toEqual([]);
});
it("treats install ledger changes as source invalidation", () => {
it("treats plugin index changes as source invalidation", () => {
const fixture = createRichPluginFixture();
const previous = loadInstalledPluginIndex({
candidates: [fixture.candidate],
config: {
plugins: {
installs: {
demo: {
source: "npm",
resolvedName: "@vendor/demo-plugin",
resolvedVersion: "1.2.3",
resolvedSpec: "@vendor/demo-plugin@1.2.3",
integrity: "sha512-old",
},
},
installRecords: {
demo: {
source: "npm",
resolvedName: "@vendor/demo-plugin",
resolvedVersion: "1.2.3",
resolvedSpec: "@vendor/demo-plugin@1.2.3",
integrity: "sha512-old",
},
},
env: hermeticEnv(),
});
const current = loadInstalledPluginIndex({
candidates: [fixture.candidate],
config: {
plugins: {
installs: {
demo: {
source: "npm",
resolvedName: "@vendor/demo-plugin",
resolvedVersion: "1.2.3",
resolvedSpec: "@vendor/demo-plugin@1.2.3",
integrity: "sha512-new",
},
},
installRecords: {
demo: {
source: "npm",
resolvedName: "@vendor/demo-plugin",
resolvedVersion: "1.2.3",
resolvedSpec: "@vendor/demo-plugin@1.2.3",
integrity: "sha512-new",
},
},
env: hermeticEnv(),
@@ -731,20 +721,16 @@ describe("installed plugin index", () => {
packageVersion: "1.2.4",
},
],
config: {
plugins: {
installs: {
demo: {
source: "npm",
resolvedVersion: "1.2.4",
},
},
installRecords: {
demo: {
source: "npm",
resolvedVersion: "1.2.4",
},
},
env: hermeticEnv({ OPENCLAW_VERSION: "2026.4.26" }),
}),
compatRegistryVersion: "different-compat-registry",
migrationVersion: 3 as 2,
migrationVersion: 2 as 1,
};
expect(diffInstalledPluginIndexInvalidationReasons(previous, current)).toEqual([

View File

@@ -7,7 +7,6 @@ import { resolveCompatibilityHostVersion } from "../version.js";
import { listPluginCompatRecords, type PluginCompatCode } from "./compat/registry.js";
import { normalizePluginsConfig, resolveEffectiveEnableState } from "./config-state.js";
import { discoverOpenClawPlugins, type PluginCandidate } from "./discovery.js";
import { loadPluginInstallRecordsSync } from "./install-ledger-store.js";
import {
describePluginInstallSource,
type PluginInstallSourceInfo,
@@ -23,9 +22,9 @@ import { safeRealpathSync } from "./path-safety.js";
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_MIGRATION_VERSION = 1;
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.";
"DO NOT EDIT. This file is generated by OpenClaw from plugin manifests, install records, and config policy. Use `openclaw plugins registry --refresh`, `openclaw plugins install/update/uninstall`, or `openclaw plugins enable/disable` instead.";
export type InstalledPluginIndexRefreshReason =
| "missing"
@@ -84,15 +83,14 @@ export type InstalledPluginIndexRecord = {
packageName?: string;
packageVersion?: string;
/**
* Actual install ledger entry recorded by OpenClaw in the plugin install
* ledger. Legacy cfg.plugins.installs is only a compatibility fallback.
* Actual install record recorded by OpenClaw in the persisted plugin index.
*/
installRecord?: InstalledPluginInstallRecordInfo;
/** Hash of installRecord; used to detect source-changed invalidation. */
installRecordHash?: string;
/**
* Package-authored openclaw.install metadata. This describes catalog/package
* install intent and must not be treated as the durable install ledger.
* install intent and must not be treated as the durable install record.
*/
packageInstall?: PluginInstallSourceInfo;
manifestPath: string;
@@ -141,7 +139,8 @@ export type LoadInstalledPluginIndexParams = {
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
stateDir?: string;
pluginInstallLedgerFilePath?: string;
pluginIndexFilePath?: string;
installRecords?: Record<string, PluginInstallRecord>;
cache?: boolean;
candidates?: PluginCandidate[];
diagnostics?: PluginDiagnostic[];
@@ -376,6 +375,28 @@ function normalizeInstallRecord(
return normalized;
}
function restoreInstallRecord(
record: InstalledPluginInstallRecordInfo | undefined,
): PluginInstallRecord | undefined {
if (!record?.source) {
return undefined;
}
return structuredClone(record) as PluginInstallRecord;
}
export function extractPluginInstallRecordsFromInstalledPluginIndex(
index: InstalledPluginIndex | null | undefined,
): Record<string, PluginInstallRecord> {
const records: Record<string, PluginInstallRecord> = {};
for (const plugin of index?.plugins ?? []) {
const record = restoreInstallRecord(plugin.installRecord);
if (record) {
records[plugin.pluginId] = record;
}
}
return records;
}
function buildCandidateLookup(
candidates: readonly PluginCandidate[],
): Map<string, PluginCandidate> {
@@ -480,12 +501,7 @@ function buildInstalledPluginIndex(
const normalizedConfig = normalizePluginsConfig(params.config?.plugins);
const diagnostics: PluginDiagnostic[] = [...registry.diagnostics];
const generatedAtMs = (params.now?.() ?? new Date()).getTime();
const installRecords = loadPluginInstallRecordsSync({
config: params.config,
env: params.env,
stateDir: params.stateDir,
filePath: params.pluginInstallLedgerFilePath,
});
const installRecords = structuredClone(params.installRecords ?? {});
const plugins = registry.plugins.map((record): InstalledPluginIndexRecord => {
const candidate = candidateByRootDir.get(record.rootDir);
const packageJsonPath = resolvePackageJsonPath(candidate);

View File

@@ -63,7 +63,7 @@ import {
} from "./config-state.js";
import { discoverOpenClawPlugins } from "./discovery.js";
import { getGlobalHookRunner, initializeGlobalHookRunner } from "./hook-runner-global.js";
import { loadPluginInstallRecordsSync } from "./install-ledger-store.js";
import { loadInstalledPluginIndexInstallRecordsSync } from "./installed-plugin-index-records.js";
import {
clearPluginInteractiveHandlers,
listPluginInteractiveHandlers,
@@ -1217,7 +1217,7 @@ function resolvePluginLoadCacheContext(options: PluginLoadOptions = {}) {
const shouldInstallBundledRuntimeDeps = options.installBundledRuntimeDeps !== false;
const runtimeSubagentMode = resolveRuntimeSubagentMode(options.runtimeOptions);
const coreGatewayMethodNames = Object.keys(options.coreGatewayHandlers ?? {}).toSorted();
const installRecords = loadPluginInstallRecordsSync({ config: cfg, env });
const installRecords = loadInstalledPluginIndexInstallRecordsSync({ env });
const cacheKey = buildCacheKey({
workspaceDir: options.workspaceDir,
plugins: trustNormalized,
@@ -1930,10 +1930,7 @@ function buildProvenanceIndex(params: {
}
const installRules = new Map<string, InstallTrackingRule>();
const installs = loadPluginInstallRecordsSync({
config: params.config,
env: params.env,
});
const installs = loadInstalledPluginIndexInstallRecordsSync({ env: params.env });
for (const [pluginId, install] of Object.entries(installs)) {
const rule: InstallTrackingRule = {
trackedWithoutPaths: false,

View File

@@ -16,7 +16,7 @@ import {
type NormalizedPluginsConfig,
} from "./config-policy.js";
import { discoverOpenClawPlugins, type PluginCandidate } from "./discovery.js";
import { loadPluginInstallRecordsSync } from "./install-ledger-store.js";
import { loadInstalledPluginIndexInstallRecordsSync } from "./installed-plugin-index-records.js";
import type { PluginManifestCommandAlias } from "./manifest-command-aliases.js";
import {
clearPluginManifestRegistryCache,
@@ -538,10 +538,7 @@ function matchesInstalledPluginRecord(params: {
if (params.candidate.origin !== "global") {
return false;
}
const record = loadPluginInstallRecordsSync({
config: params.config,
env: params.env,
})[params.pluginId];
const record = loadInstalledPluginIndexInstallRecordsSync({ env: params.env })[params.pluginId];
if (!record) {
return false;
}

View File

@@ -102,7 +102,7 @@ function createIndex(
version: 1,
hostContractVersion: "2026.4.25",
compatRegistryVersion: "compat-v1",
migrationVersion: 2,
migrationVersion: 1,
policyHash: "policy-v1",
generatedAtMs: 1777118400000,
plugins: [

View File

@@ -11,6 +11,7 @@ import {
} from "./installed-plugin-index-store.js";
import {
getInstalledPluginRecord,
extractPluginInstallRecordsFromInstalledPluginIndex,
isInstalledPluginEnabled,
listInstalledPluginContributionIds,
listInstalledPluginRecords,
@@ -200,7 +201,14 @@ export function loadPluginRegistrySnapshotWithMetadata(
}
return {
snapshot: loadInstalledPluginIndex(params),
snapshot: loadInstalledPluginIndex({
...params,
installRecords:
params.installRecords ??
extractPluginInstallRecordsFromInstalledPluginIndex(
persistedReadsEnabled ? readPersistedInstalledPluginIndexSync(params) : null,
),
}),
source: "derived",
diagnostics,
};

View File

@@ -1040,7 +1040,7 @@ describe("syncPluginsForUpdateChannel", () => {
}
});
it("installs an externalized bundled plugin and rewrites its old bundled path ledger", async () => {
it("installs an externalized bundled plugin and rewrites its old bundled path plugin index", async () => {
resolveBundledPluginSourcesMock.mockReturnValue(new Map());
installPluginFromNpmSpecMock.mockResolvedValue(
createSuccessfulNpmUpdateResult({