mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 07:20:43 +00:00
refactor: consolidate plugin install index store
This commit is contained in:
@@ -55,8 +55,10 @@ vi.mock("../../plugins/install.js", () => ({
|
||||
installPluginFromPath: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../plugins/install-ledger-store.js", () => ({
|
||||
loadPluginInstallRecords: vi.fn(async ({ config }) => config?.plugins?.installs ?? {}),
|
||||
vi.mock("../../plugins/installed-plugin-index-records.js", () => ({
|
||||
loadInstalledPluginIndexInstallRecords: vi.fn(
|
||||
async ({ config }) => config?.plugins?.installs ?? {},
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../../plugins/manifest-registry.js", () => ({
|
||||
|
||||
@@ -18,8 +18,8 @@ import type { PluginInstallRecord } from "../../config/types.plugins.js";
|
||||
import { resolveArchiveKind } from "../../infra/archive.js";
|
||||
import { parseClawHubPluginSpec } from "../../infra/clawhub.js";
|
||||
import { installPluginFromClawHub } from "../../plugins/clawhub.js";
|
||||
import { loadPluginInstallRecords } from "../../plugins/install-ledger-store.js";
|
||||
import { installPluginFromNpmSpec, installPluginFromPath } from "../../plugins/install.js";
|
||||
import { loadInstalledPluginIndexInstallRecords } from "../../plugins/installed-plugin-index-records.js";
|
||||
import { clearPluginManifestRegistryCache } from "../../plugins/manifest-registry.js";
|
||||
import type { PluginRecord } from "../../plugins/registry.js";
|
||||
import {
|
||||
@@ -416,7 +416,7 @@ export const handlePluginsCommand: CommandHandler = async (params, allowTextComm
|
||||
}
|
||||
|
||||
if (pluginsCommand.action === "inspect") {
|
||||
const installRecords = await loadPluginInstallRecords({ config: loaded.config });
|
||||
const installRecords = await loadInstalledPluginIndexInstallRecords();
|
||||
if (!pluginsCommand.name) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
|
||||
@@ -33,11 +33,15 @@ export const listMarketplacePlugins: Mock<ListMarketplacePluginsFn> = vi.fn();
|
||||
export const resolveMarketplaceInstallShortcut: Mock<ResolveMarketplaceInstallShortcutFn> = vi.fn();
|
||||
export const enablePluginInConfig: UnknownMock = vi.fn();
|
||||
export const recordPluginInstall: UnknownMock = vi.fn();
|
||||
export const loadPluginInstallRecords: AsyncUnknownMock = vi.fn(async (...args: unknown[]) => {
|
||||
const params = args[0] as LoadPluginInstallRecordsParams | undefined;
|
||||
return structuredClone(params?.config?.plugins?.installs ?? {});
|
||||
});
|
||||
export const writePersistedPluginInstallLedger: AsyncUnknownMock = vi.fn(async () => undefined);
|
||||
export const loadInstalledPluginIndexInstallRecords: AsyncUnknownMock = vi.fn(
|
||||
async (...args: unknown[]) => {
|
||||
const params = args[0] as LoadPluginInstallRecordsParams | undefined;
|
||||
return structuredClone(params?.config?.plugins?.installs ?? {});
|
||||
},
|
||||
);
|
||||
export const writePersistedInstalledPluginIndexInstallRecords: AsyncUnknownMock = vi.fn(
|
||||
async () => undefined,
|
||||
);
|
||||
export const clearPluginManifestRegistryCache: UnknownMock = vi.fn();
|
||||
export const loadPluginManifestRegistry: UnknownMock = vi.fn();
|
||||
export const buildPluginSnapshotReport: UnknownMock = vi.fn();
|
||||
@@ -157,18 +161,20 @@ vi.mock("../plugins/installs.js", () => ({
|
||||
)) as (typeof import("../plugins/installs.js"))["recordPluginInstall"],
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/install-ledger-store.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../plugins/install-ledger-store.js")>();
|
||||
vi.mock("../plugins/installed-plugin-index-records.js", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("../plugins/installed-plugin-index-records.js")>();
|
||||
return {
|
||||
...actual,
|
||||
loadPluginInstallRecords: ((...args: unknown[]) =>
|
||||
invokeMock<unknown[], unknown>(loadPluginInstallRecords, ...args)) as (
|
||||
...args: unknown[]
|
||||
) => unknown,
|
||||
writePersistedPluginInstallLedger: ((...args: unknown[]) =>
|
||||
invokeMock<unknown[], unknown>(writePersistedPluginInstallLedger, ...args)) as (
|
||||
loadInstalledPluginIndexInstallRecords: ((...args: unknown[]) =>
|
||||
invokeMock<unknown[], unknown>(loadInstalledPluginIndexInstallRecords, ...args)) as (
|
||||
...args: unknown[]
|
||||
) => unknown,
|
||||
writePersistedInstalledPluginIndexInstallRecords: ((...args: unknown[]) =>
|
||||
invokeMock<unknown[], unknown>(
|
||||
writePersistedInstalledPluginIndexInstallRecords,
|
||||
...args,
|
||||
)) as (...args: unknown[]) => unknown,
|
||||
recordPluginInstallInRecords: (
|
||||
records: Record<string, unknown>,
|
||||
update: { pluginId: string; installedAt?: string } & Record<string, unknown>,
|
||||
@@ -459,8 +465,8 @@ export function resetPluginsCliTestState() {
|
||||
resolveMarketplaceInstallShortcut.mockReset();
|
||||
enablePluginInConfig.mockReset();
|
||||
recordPluginInstall.mockReset();
|
||||
loadPluginInstallRecords.mockReset();
|
||||
writePersistedPluginInstallLedger.mockReset();
|
||||
loadInstalledPluginIndexInstallRecords.mockReset();
|
||||
writePersistedInstalledPluginIndexInstallRecords.mockReset();
|
||||
clearPluginManifestRegistryCache.mockReset();
|
||||
loadPluginManifestRegistry.mockReset();
|
||||
buildPluginSnapshotReport.mockReset();
|
||||
@@ -519,11 +525,11 @@ export function resetPluginsCliTestState() {
|
||||
recordPluginInstall.mockImplementation(
|
||||
((cfg: OpenClawConfig) => cfg) as (...args: unknown[]) => unknown,
|
||||
);
|
||||
loadPluginInstallRecords.mockImplementation(async (...args: unknown[]) => {
|
||||
loadInstalledPluginIndexInstallRecords.mockImplementation(async (...args: unknown[]) => {
|
||||
const params = args[0] as LoadPluginInstallRecordsParams | undefined;
|
||||
return structuredClone(params?.config?.plugins?.installs ?? {});
|
||||
});
|
||||
writePersistedPluginInstallLedger.mockResolvedValue(undefined);
|
||||
writePersistedInstalledPluginIndexInstallRecords.mockResolvedValue(undefined);
|
||||
loadPluginManifestRegistry.mockReturnValue({
|
||||
plugins: [],
|
||||
diagnostics: [],
|
||||
@@ -544,7 +550,7 @@ export function resetPluginsCliTestState() {
|
||||
version: 1,
|
||||
hostContractVersion: "2026.4.25",
|
||||
compatRegistryVersion: "compat-v1",
|
||||
migrationVersion: 2,
|
||||
migrationVersion: 1,
|
||||
policyHash: "policy-v1",
|
||||
generatedAtMs: 1777118400000,
|
||||
plugins: [],
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
runtimeErrors,
|
||||
runtimeLogs,
|
||||
writeConfigFile,
|
||||
writePersistedPluginInstallLedger,
|
||||
writePersistedInstalledPluginIndexInstallRecords,
|
||||
} from "./plugins-cli-test-helpers.js";
|
||||
|
||||
const CLI_STATE_ROOT = "/tmp/openclaw-state";
|
||||
@@ -290,7 +290,7 @@ describe("plugins cli install", () => {
|
||||
expect(writeConfigFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("installs marketplace plugins and persists install ledger", async () => {
|
||||
it("installs marketplace plugins and persists plugin index", async () => {
|
||||
const cfg = {
|
||||
plugins: {
|
||||
entries: {},
|
||||
@@ -329,7 +329,7 @@ describe("plugins cli install", () => {
|
||||
await runPluginsCommand(["plugins", "install", "alpha", "--marketplace", "local/repo"]);
|
||||
|
||||
expect(clearPluginManifestRegistryCache).toHaveBeenCalledTimes(1);
|
||||
expect(writePersistedPluginInstallLedger).toHaveBeenCalledWith({
|
||||
expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith({
|
||||
alpha: expect.objectContaining({
|
||||
source: "marketplace",
|
||||
installPath: cliInstallPath("alpha"),
|
||||
@@ -384,7 +384,7 @@ describe("plugins cli install", () => {
|
||||
spec: "clawhub:demo",
|
||||
}),
|
||||
);
|
||||
expect(writePersistedPluginInstallLedger).toHaveBeenCalledWith({
|
||||
expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith({
|
||||
demo: expect.objectContaining({
|
||||
source: "clawhub",
|
||||
spec: "clawhub:demo@1.2.3",
|
||||
@@ -464,7 +464,7 @@ describe("plugins cli install", () => {
|
||||
}),
|
||||
);
|
||||
expect(installPluginFromNpmSpec).not.toHaveBeenCalled();
|
||||
expect(writePersistedPluginInstallLedger).toHaveBeenCalledWith({
|
||||
expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith({
|
||||
demo: expect.objectContaining({
|
||||
source: "clawhub",
|
||||
spec: "clawhub:demo@1.2.3",
|
||||
|
||||
@@ -7,13 +7,13 @@ import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import type { PluginInstallRecord } from "../config/types.plugins.js";
|
||||
import { enablePluginInConfig } from "../plugins/enable.js";
|
||||
import {
|
||||
loadPluginInstallRecords,
|
||||
loadInstalledPluginIndexInstallRecords,
|
||||
PLUGIN_INSTALLS_CONFIG_PATH,
|
||||
removePluginInstallRecordFromRecords,
|
||||
withoutPluginInstallRecords,
|
||||
writePersistedPluginInstallLedger,
|
||||
writePersistedInstalledPluginIndexInstallRecords,
|
||||
withPluginInstallRecords,
|
||||
} from "../plugins/install-ledger-store.js";
|
||||
} from "../plugins/installed-plugin-index-records.js";
|
||||
import { listMarketplacePlugins } from "../plugins/marketplace.js";
|
||||
import { inspectPluginRegistry, refreshPluginRegistry } from "../plugins/plugin-registry.js";
|
||||
import { defaultSlotIdForKey } from "../plugins/slots.js";
|
||||
@@ -290,7 +290,7 @@ export function registerPluginsCli(program: Command) {
|
||||
.option("--json", "Print JSON")
|
||||
.action(async (id: string | undefined, opts: PluginInspectOptions) => {
|
||||
const cfg = loadConfig();
|
||||
const installRecords = await loadPluginInstallRecords({ config: cfg });
|
||||
const installRecords = await loadInstalledPluginIndexInstallRecords();
|
||||
const report = buildPluginDiagnosticsReport({
|
||||
config: cfg,
|
||||
...(opts.json ? { logger: quietPluginJsonLogger } : {}),
|
||||
@@ -584,7 +584,7 @@ export function registerPluginsCli(program: Command) {
|
||||
.action(async (id: string, opts: PluginUninstallOptions) => {
|
||||
const snapshot = await readConfigFileSnapshot();
|
||||
const sourceConfig = (snapshot.sourceConfig ?? snapshot.config) as OpenClawConfig;
|
||||
const installRecords = await loadPluginInstallRecords({ config: sourceConfig });
|
||||
const installRecords = await loadInstalledPluginIndexInstallRecords();
|
||||
const cfg = withPluginInstallRecords(sourceConfig, installRecords);
|
||||
const report = buildPluginDiagnosticsReport({ config: cfg });
|
||||
const extensionsDir = path.join(resolveStateDir(process.env, os.homedir), "extensions");
|
||||
@@ -691,7 +691,7 @@ export function registerPluginsCli(program: Command) {
|
||||
defaultRuntime.log(theme.warn(warning));
|
||||
}
|
||||
|
||||
await writePersistedPluginInstallLedger(
|
||||
await writePersistedInstalledPluginIndexInstallRecords(
|
||||
removePluginInstallRecordFromRecords(installRecords, pluginId),
|
||||
);
|
||||
await replaceConfigFile({
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
runtimeLogs,
|
||||
uninstallPlugin,
|
||||
writeConfigFile,
|
||||
writePersistedPluginInstallLedger,
|
||||
writePersistedInstalledPluginIndexInstallRecords,
|
||||
} from "./plugins-cli-test-helpers.js";
|
||||
|
||||
const CLI_STATE_ROOT = "/tmp/openclaw-state";
|
||||
@@ -103,7 +103,7 @@ describe("plugins cli uninstall", () => {
|
||||
deleteFiles: false,
|
||||
}),
|
||||
);
|
||||
expect(writePersistedPluginInstallLedger).toHaveBeenCalledWith({});
|
||||
expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith({});
|
||||
expect(writeConfigFile).toHaveBeenCalledWith({
|
||||
plugins: {
|
||||
entries: {},
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
updateNpmInstalledHookPacks,
|
||||
updateNpmInstalledPlugins,
|
||||
writeConfigFile,
|
||||
writePersistedPluginInstallLedger,
|
||||
writePersistedInstalledPluginIndexInstallRecords,
|
||||
} from "./plugins-cli-test-helpers.js";
|
||||
|
||||
function createTrackedPluginConfig(params: {
|
||||
@@ -211,7 +211,9 @@ describe("plugins cli update", () => {
|
||||
dryRun: false,
|
||||
}),
|
||||
);
|
||||
expect(writePersistedPluginInstallLedger).toHaveBeenCalledWith(nextConfig.plugins?.installs);
|
||||
expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith(
|
||||
nextConfig.plugins?.installs,
|
||||
);
|
||||
expect(writeConfigFile).toHaveBeenCalledWith({});
|
||||
expect(refreshPluginRegistry).toHaveBeenCalledWith({
|
||||
config: {},
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
refreshPluginRegistry,
|
||||
resetPluginsCliTestState,
|
||||
writeConfigFile,
|
||||
writePersistedPluginInstallLedger,
|
||||
writePersistedInstalledPluginIndexInstallRecords,
|
||||
} from "./plugins-cli-test-helpers.js";
|
||||
|
||||
describe("persistPluginInstall", () => {
|
||||
@@ -46,7 +46,7 @@ describe("persistPluginInstall", () => {
|
||||
});
|
||||
|
||||
expect(next).toEqual(enabledConfig);
|
||||
expect(writePersistedPluginInstallLedger).toHaveBeenCalledWith({
|
||||
expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith({
|
||||
alpha: expect.objectContaining({
|
||||
source: "npm",
|
||||
spec: "alpha@1.0.0",
|
||||
|
||||
@@ -3,12 +3,12 @@ import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { type HookInstallUpdate, recordHookInstall } from "../hooks/installs.js";
|
||||
import { enablePluginInConfig } from "../plugins/enable.js";
|
||||
import {
|
||||
loadPluginInstallRecords,
|
||||
loadInstalledPluginIndexInstallRecords,
|
||||
PLUGIN_INSTALLS_CONFIG_PATH,
|
||||
recordPluginInstallInRecords,
|
||||
withoutPluginInstallRecords,
|
||||
writePersistedPluginInstallLedger,
|
||||
} from "../plugins/install-ledger-store.js";
|
||||
writePersistedInstalledPluginIndexInstallRecords,
|
||||
} from "../plugins/installed-plugin-index-records.js";
|
||||
import type { PluginInstallUpdate } from "../plugins/installs.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { theme } from "../terminal/theme.js";
|
||||
@@ -46,14 +46,14 @@ export async function persistPluginInstall(params: {
|
||||
addInstalledPluginToAllowlist(params.config, params.pluginId),
|
||||
params.pluginId,
|
||||
).config;
|
||||
const installRecords = await loadPluginInstallRecords({ config: params.config });
|
||||
const installRecords = await loadInstalledPluginIndexInstallRecords();
|
||||
const nextInstallRecords = recordPluginInstallInRecords(installRecords, {
|
||||
pluginId: params.pluginId,
|
||||
...params.install,
|
||||
});
|
||||
const slotResult = applySlotSelectionForPlugin(next, params.pluginId);
|
||||
next = withoutPluginInstallRecords(slotResult.config);
|
||||
await writePersistedPluginInstallLedger(nextInstallRecords);
|
||||
await writePersistedInstalledPluginIndexInstallRecords(nextInstallRecords);
|
||||
await replaceConfigFile({
|
||||
nextConfig: next,
|
||||
...(params.baseHash !== undefined ? { baseHash: params.baseHash } : {}),
|
||||
@@ -62,6 +62,7 @@ export async function persistPluginInstall(params: {
|
||||
await refreshPluginRegistryAfterConfigMutation({
|
||||
config: next,
|
||||
reason: "source-changed",
|
||||
installRecords: nextInstallRecords,
|
||||
logger: {
|
||||
warn: (message) => defaultRuntime.log(theme.warn(message)),
|
||||
},
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
import { loadInstalledPluginIndexInstallRecords } from "../plugins/installed-plugin-index-records.js";
|
||||
import type { InstalledPluginIndexRefreshReason } from "../plugins/installed-plugin-index.js";
|
||||
import { refreshPluginRegistry } from "../plugins/plugin-registry.js";
|
||||
|
||||
@@ -12,12 +13,17 @@ export async function refreshPluginRegistryAfterConfigMutation(params: {
|
||||
reason: InstalledPluginIndexRefreshReason;
|
||||
workspaceDir?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
installRecords?: Awaited<ReturnType<typeof loadInstalledPluginIndexInstallRecords>>;
|
||||
logger?: PluginRegistryRefreshLogger;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const installRecords =
|
||||
params.installRecords ??
|
||||
(await loadInstalledPluginIndexInstallRecords(params.env ? { env: params.env } : {}));
|
||||
await refreshPluginRegistry({
|
||||
config: params.config,
|
||||
reason: params.reason,
|
||||
installRecords,
|
||||
...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}),
|
||||
...(params.env ? { env: params.env } : {}),
|
||||
});
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { loadConfig, readConfigFileSnapshot, replaceConfigFile } from "../config/config.js";
|
||||
import { updateNpmInstalledHookPacks } from "../hooks/update.js";
|
||||
import {
|
||||
loadPluginInstallRecords,
|
||||
loadInstalledPluginIndexInstallRecords,
|
||||
PLUGIN_INSTALLS_CONFIG_PATH,
|
||||
withoutPluginInstallRecords,
|
||||
writePersistedPluginInstallLedger,
|
||||
writePersistedInstalledPluginIndexInstallRecords,
|
||||
withPluginInstallRecords,
|
||||
} from "../plugins/install-ledger-store.js";
|
||||
} from "../plugins/installed-plugin-index-records.js";
|
||||
import { updateNpmInstalledPlugins } from "../plugins/update.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { theme } from "../terminal/theme.js";
|
||||
@@ -23,7 +23,7 @@ export async function runPluginUpdateCommand(params: {
|
||||
}) {
|
||||
const sourceSnapshotPromise = readConfigFileSnapshot().catch(() => null);
|
||||
const cfg = loadConfig();
|
||||
const pluginInstallRecords = await loadPluginInstallRecords({ config: cfg });
|
||||
const pluginInstallRecords = await loadInstalledPluginIndexInstallRecords();
|
||||
const cfgWithPluginInstallRecords = withPluginInstallRecords(cfg, pluginInstallRecords);
|
||||
const logger = {
|
||||
info: (msg: string) => defaultRuntime.log(msg),
|
||||
@@ -119,18 +119,18 @@ export async function runPluginUpdateCommand(params: {
|
||||
|
||||
if (!params.opts.dryRun && (pluginResult.changed || hookResult.changed)) {
|
||||
const nextPluginInstallRecords = pluginResult.config.plugins?.installs ?? {};
|
||||
const shouldPersistPluginInstallLedger =
|
||||
const shouldPersistPluginInstallIndex =
|
||||
pluginResult.changed || Object.keys(pluginInstallRecords).length > 0;
|
||||
if (shouldPersistPluginInstallLedger) {
|
||||
await writePersistedPluginInstallLedger(nextPluginInstallRecords);
|
||||
if (shouldPersistPluginInstallIndex) {
|
||||
await writePersistedInstalledPluginIndexInstallRecords(nextPluginInstallRecords);
|
||||
}
|
||||
const nextConfig = shouldPersistPluginInstallLedger
|
||||
const nextConfig = shouldPersistPluginInstallIndex
|
||||
? withoutPluginInstallRecords(hookResult.config)
|
||||
: hookResult.config;
|
||||
await replaceConfigFile({
|
||||
nextConfig,
|
||||
baseHash: (await sourceSnapshotPromise)?.hash,
|
||||
...(shouldPersistPluginInstallLedger
|
||||
...(shouldPersistPluginInstallIndex
|
||||
? { writeOptions: { unsetPaths: [Array.from(PLUGIN_INSTALLS_CONFIG_PATH)] } }
|
||||
: {}),
|
||||
});
|
||||
@@ -138,6 +138,7 @@ export async function runPluginUpdateCommand(params: {
|
||||
await refreshPluginRegistryAfterConfigMutation({
|
||||
config: nextConfig,
|
||||
reason: "source-changed",
|
||||
installRecords: nextPluginInstallRecords,
|
||||
logger,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -148,12 +148,15 @@ vi.mock("../plugins/update.js", () => ({
|
||||
updateNpmInstalledPlugins: (...args: unknown[]) => updateNpmInstalledPlugins(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/install-ledger-store.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../plugins/install-ledger-store.js")>();
|
||||
vi.mock("../plugins/installed-plugin-index-records.js", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("../plugins/installed-plugin-index-records.js")>();
|
||||
return {
|
||||
...actual,
|
||||
loadPluginInstallRecords: vi.fn(async ({ config }) => config?.plugins?.installs ?? {}),
|
||||
writePersistedPluginInstallLedger: vi.fn(async () => undefined),
|
||||
loadInstalledPluginIndexInstallRecords: vi.fn(
|
||||
async ({ config }) => config?.plugins?.installs ?? {},
|
||||
),
|
||||
writePersistedInstalledPluginIndexInstallRecords: vi.fn(async () => undefined),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -43,12 +43,12 @@ import {
|
||||
} from "../../infra/update-global.js";
|
||||
import { runGatewayUpdate, type UpdateRunResult } from "../../infra/update-runner.js";
|
||||
import {
|
||||
loadPluginInstallRecords,
|
||||
loadInstalledPluginIndexInstallRecords,
|
||||
PLUGIN_INSTALLS_CONFIG_PATH,
|
||||
withoutPluginInstallRecords,
|
||||
writePersistedPluginInstallLedger,
|
||||
writePersistedInstalledPluginIndexInstallRecords,
|
||||
withPluginInstallRecords,
|
||||
} from "../../plugins/install-ledger-store.js";
|
||||
} from "../../plugins/installed-plugin-index-records.js";
|
||||
import { syncPluginsForUpdateChannel, updateNpmInstalledPlugins } from "../../plugins/update.js";
|
||||
import { runCommandWithTimeout } from "../../process/exec.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
@@ -584,9 +584,7 @@ async function updatePluginsAfterCoreUpdate(params: {
|
||||
defaultRuntime.log(theme.heading("Updating plugins..."));
|
||||
}
|
||||
|
||||
const pluginInstallRecords = await loadPluginInstallRecords({
|
||||
config: params.configSnapshot.sourceConfig,
|
||||
});
|
||||
const pluginInstallRecords = await loadInstalledPluginIndexInstallRecords();
|
||||
const syncResult = await syncPluginsForUpdateChannel({
|
||||
config: withPluginInstallRecords(params.configSnapshot.sourceConfig, pluginInstallRecords),
|
||||
channel: params.channel,
|
||||
@@ -630,7 +628,7 @@ async function updatePluginsAfterCoreUpdate(params: {
|
||||
pluginConfig = npmResult.config;
|
||||
|
||||
if (syncResult.changed || npmResult.changed) {
|
||||
await writePersistedPluginInstallLedger(pluginConfig.plugins?.installs ?? {});
|
||||
await writePersistedInstalledPluginIndexInstallRecords(pluginConfig.plugins?.installs ?? {});
|
||||
const nextConfig = withoutPluginInstallRecords(pluginConfig);
|
||||
await replaceConfigFile({
|
||||
nextConfig,
|
||||
|
||||
@@ -10,8 +10,8 @@ import { replaceConfigFile, type OpenClawConfig } from "../../config/config.js";
|
||||
import {
|
||||
PLUGIN_INSTALLS_CONFIG_PATH,
|
||||
withoutPluginInstallRecords,
|
||||
writePersistedPluginInstallLedger,
|
||||
} from "../../plugins/install-ledger-store.js";
|
||||
writePersistedInstalledPluginIndexInstallRecords,
|
||||
} from "../../plugins/installed-plugin-index-records.js";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js";
|
||||
import { defaultRuntime, type RuntimeEnv } from "../../runtime.js";
|
||||
import { normalizeOptionalLowercaseString } from "../../shared/string-coerce.js";
|
||||
@@ -251,7 +251,7 @@ export async function channelsAddCommand(
|
||||
? withoutPluginInstallRecords(nextConfig)
|
||||
: nextConfig;
|
||||
if (shouldMovePluginInstalls) {
|
||||
await writePersistedPluginInstallLedger(nextConfig.plugins?.installs ?? {});
|
||||
await writePersistedInstalledPluginIndexInstallRecords(nextConfig.plugins?.installs ?? {});
|
||||
}
|
||||
await replaceConfigFile({
|
||||
nextConfig: writtenConfig,
|
||||
@@ -402,7 +402,7 @@ export async function channelsAddCommand(
|
||||
? withoutPluginInstallRecords(nextConfig)
|
||||
: nextConfig;
|
||||
if (shouldMovePluginInstalls) {
|
||||
await writePersistedPluginInstallLedger(nextConfig.plugins?.installs ?? {});
|
||||
await writePersistedInstalledPluginIndexInstallRecords(nextConfig.plugins?.installs ?? {});
|
||||
}
|
||||
await replaceConfigFile({
|
||||
nextConfig: writtenConfig,
|
||||
|
||||
@@ -21,8 +21,8 @@ import { formatErrorMessage } from "../../infra/errors.js";
|
||||
import {
|
||||
PLUGIN_INSTALLS_CONFIG_PATH,
|
||||
withoutPluginInstallRecords,
|
||||
writePersistedPluginInstallLedger,
|
||||
} from "../../plugins/install-ledger-store.js";
|
||||
writePersistedInstalledPluginIndexInstallRecords,
|
||||
} from "../../plugins/installed-plugin-index-records.js";
|
||||
import { defaultRuntime, type RuntimeEnv, writeRuntimeJson } from "../../runtime.js";
|
||||
import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
@@ -260,7 +260,7 @@ export async function channelsCapabilitiesCommand(
|
||||
cfg.plugins?.installs && Object.keys(cfg.plugins.installs).length > 0,
|
||||
);
|
||||
if (shouldMovePluginInstalls) {
|
||||
await writePersistedPluginInstallLedger(cfg.plugins?.installs ?? {});
|
||||
await writePersistedInstalledPluginIndexInstallRecords(cfg.plugins?.installs ?? {});
|
||||
cfg = withoutPluginInstallRecords(cfg);
|
||||
}
|
||||
await replaceConfigFile({
|
||||
|
||||
@@ -9,8 +9,8 @@ import { replaceConfigFile, type OpenClawConfig } from "../../config/config.js";
|
||||
import {
|
||||
PLUGIN_INSTALLS_CONFIG_PATH,
|
||||
withoutPluginInstallRecords,
|
||||
writePersistedPluginInstallLedger,
|
||||
} from "../../plugins/install-ledger-store.js";
|
||||
writePersistedInstalledPluginIndexInstallRecords,
|
||||
} from "../../plugins/installed-plugin-index-records.js";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js";
|
||||
import { defaultRuntime, type RuntimeEnv } from "../../runtime.js";
|
||||
import { normalizeOptionalString } from "../../shared/string-coerce.js";
|
||||
@@ -181,7 +181,7 @@ export async function channelsRemoveCommand(
|
||||
next.plugins?.installs && Object.keys(next.plugins.installs).length > 0,
|
||||
);
|
||||
if (shouldMovePluginInstalls) {
|
||||
await writePersistedPluginInstallLedger(next.plugins?.installs ?? {});
|
||||
await writePersistedInstalledPluginIndexInstallRecords(next.plugins?.installs ?? {});
|
||||
next = withoutPluginInstallRecords(next);
|
||||
}
|
||||
await replaceConfigFile({
|
||||
|
||||
@@ -12,8 +12,8 @@ import { resolveMessageChannelSelection } from "../../infra/outbound/channel-sel
|
||||
import {
|
||||
PLUGIN_INSTALLS_CONFIG_PATH,
|
||||
withoutPluginInstallRecords,
|
||||
writePersistedPluginInstallLedger,
|
||||
} from "../../plugins/install-ledger-store.js";
|
||||
writePersistedInstalledPluginIndexInstallRecords,
|
||||
} from "../../plugins/installed-plugin-index-records.js";
|
||||
import { type RuntimeEnv, writeRuntimeJson } from "../../runtime.js";
|
||||
import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
@@ -149,7 +149,7 @@ export async function channelsResolveCommand(opts: ChannelsResolveOptions, runti
|
||||
cfg.plugins?.installs && Object.keys(cfg.plugins.installs).length > 0,
|
||||
);
|
||||
if (shouldMovePluginInstalls) {
|
||||
await writePersistedPluginInstallLedger(cfg.plugins?.installs ?? {});
|
||||
await writePersistedInstalledPluginIndexInstallRecords(cfg.plugins?.installs ?? {});
|
||||
cfg = withoutPluginInstallRecords(cfg);
|
||||
}
|
||||
await replaceConfigFile({
|
||||
|
||||
@@ -203,7 +203,7 @@ describe("loadProviderCatalogModelsForList", () => {
|
||||
expect(providerDiscoveryMocks.resolveOwningPluginIdsForProvider).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not fall back to legacy manifest ownership for disabled installed-index owners", async () => {
|
||||
it("does not fall back to legacy manifest ownership for disabled persisted plugin owners", async () => {
|
||||
providerDiscoveryMocks.resolveProviderOwners
|
||||
.mockReturnValueOnce([])
|
||||
.mockReturnValueOnce(["moonshot"]);
|
||||
|
||||
@@ -9,13 +9,13 @@ import {
|
||||
resolveBundledPluginSources,
|
||||
} from "../plugins/bundled-sources.js";
|
||||
import { enablePluginInConfig, type PluginEnableResult } from "../plugins/enable.js";
|
||||
import { installPluginFromNpmSpec } from "../plugins/install.js";
|
||||
import {
|
||||
loadPluginInstallRecords,
|
||||
loadInstalledPluginIndexInstallRecords,
|
||||
recordPluginInstallInRecords,
|
||||
withoutPluginInstallRecords,
|
||||
writePersistedPluginInstallLedger,
|
||||
} from "../plugins/install-ledger-store.js";
|
||||
import { installPluginFromNpmSpec } from "../plugins/install.js";
|
||||
writePersistedInstalledPluginIndexInstallRecords,
|
||||
} from "../plugins/installed-plugin-index-records.js";
|
||||
import { buildNpmResolutionInstallFields, recordPluginInstall } from "../plugins/installs.js";
|
||||
import type { PluginPackageInstall } from "../plugins/manifest.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
@@ -146,8 +146,10 @@ async function persistOnboardingPluginInstallRecord(params: {
|
||||
cfg: OpenClawConfig;
|
||||
install: Parameters<typeof recordPluginInstallInRecords>[1];
|
||||
}) {
|
||||
const records = await loadPluginInstallRecords({ config: params.cfg });
|
||||
await writePersistedPluginInstallLedger(recordPluginInstallInRecords(records, params.install));
|
||||
const records = await loadInstalledPluginIndexInstallRecords();
|
||||
await writePersistedInstalledPluginIndexInstallRecords(
|
||||
recordPluginInstallInRecords(records, params.install),
|
||||
);
|
||||
}
|
||||
|
||||
async function refreshRegistryAfterOnboardingPluginInstall(params: {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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({});
|
||||
});
|
||||
});
|
||||
127
src/plugins/installed-plugin-index-records.ts
Normal file
127
src/plugins/installed-plugin-index-records.ts
Normal 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;
|
||||
}
|
||||
@@ -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"),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -62,8 +62,8 @@ const {
|
||||
)[value],
|
||||
),
|
||||
}));
|
||||
const { loadPluginInstallRecordsSyncMock } = vi.hoisted(() => ({
|
||||
loadPluginInstallRecordsSyncMock: vi.fn(() => ({})),
|
||||
const { loadInstalledPluginIndexInstallRecordsSyncMock } = vi.hoisted(() => ({
|
||||
loadInstalledPluginIndexInstallRecordsSyncMock: vi.fn(() => ({})),
|
||||
}));
|
||||
let secretResolve: typeof import("./resolve.js");
|
||||
let createResolverContext: typeof import("./runtime-shared.js").createResolverContext;
|
||||
@@ -105,13 +105,13 @@ vi.mock("./runtime-web-tools-manifest.runtime.js", () => ({
|
||||
resolveManifestContractPluginIdsByCompatibilityRuntimePathMock,
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/install-ledger-store.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../plugins/install-ledger-store.js")>(
|
||||
"../plugins/install-ledger-store.js",
|
||||
);
|
||||
vi.mock("../plugins/installed-plugin-index-records.js", async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import("../plugins/installed-plugin-index-records.js")
|
||||
>("../plugins/installed-plugin-index-records.js");
|
||||
return {
|
||||
...actual,
|
||||
loadPluginInstallRecordsSync: loadPluginInstallRecordsSyncMock,
|
||||
loadInstalledPluginIndexInstallRecordsSync: loadInstalledPluginIndexInstallRecordsSyncMock,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -335,8 +335,8 @@ describe("runtime web tools resolution", () => {
|
||||
resolveManifestContractOwnerPluginIdMock.mockClear();
|
||||
resolveManifestContractPluginIdsMock.mockClear();
|
||||
resolveManifestContractPluginIdsByCompatibilityRuntimePathMock.mockClear();
|
||||
loadPluginInstallRecordsSyncMock.mockReset();
|
||||
loadPluginInstallRecordsSyncMock.mockReturnValue({});
|
||||
loadInstalledPluginIndexInstallRecordsSyncMock.mockReset();
|
||||
loadInstalledPluginIndexInstallRecordsSyncMock.mockReturnValue({});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -1089,8 +1089,8 @@ describe("runtime web tools resolution", () => {
|
||||
expect(resolvePluginWebSearchProvidersMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses runtime web search discovery when the managed plugin install ledger is populated", async () => {
|
||||
loadPluginInstallRecordsSyncMock.mockReturnValue({
|
||||
it("uses runtime web search discovery when the managed plugin index install records is populated", async () => {
|
||||
loadInstalledPluginIndexInstallRecordsSyncMock.mockReturnValue({
|
||||
"external-search": {
|
||||
source: "npm",
|
||||
spec: "@openclaw/external-search",
|
||||
@@ -1141,8 +1141,8 @@ describe("runtime web tools resolution", () => {
|
||||
expect(resolvePluginWebFetchProvidersMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses runtime web fetch discovery when the managed plugin install ledger is populated", async () => {
|
||||
loadPluginInstallRecordsSyncMock.mockReturnValue({
|
||||
it("uses runtime web fetch discovery when the managed plugin index install records is populated", async () => {
|
||||
loadInstalledPluginIndexInstallRecordsSyncMock.mockReturnValue({
|
||||
"external-fetch": {
|
||||
source: "npm",
|
||||
spec: "@openclaw/external-fetch",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { resolveSecretInputRef } from "../config/types.secrets.js";
|
||||
import { loadPluginInstallRecordsSync } from "../plugins/install-ledger-store.js";
|
||||
import { loadInstalledPluginIndexInstallRecordsSync } from "../plugins/installed-plugin-index-records.js";
|
||||
import type {
|
||||
PluginWebFetchProviderEntry,
|
||||
PluginWebSearchProviderEntry,
|
||||
@@ -128,10 +128,7 @@ async function hasCustomWebProviderPluginRisk(params: {
|
||||
config: OpenClawConfig;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): Promise<boolean> {
|
||||
const installRecords = loadPluginInstallRecordsSync({
|
||||
config: params.config,
|
||||
env: params.env,
|
||||
});
|
||||
const installRecords = loadInstalledPluginIndexInstallRecordsSync({ env: params.env });
|
||||
if (Object.keys(installRecords).length > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,9 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import type { PluginInstallRecord } from "../config/types.plugins.js";
|
||||
import { writePersistedInstalledPluginIndex } from "../plugins/installed-plugin-index-store.js";
|
||||
import type { InstalledPluginIndex } from "../plugins/installed-plugin-index.js";
|
||||
import { createPathResolutionEnv, withEnvAsync } from "../test-utils/env.js";
|
||||
import { collectPluginsTrustFindings } from "./audit-plugins-trust.js";
|
||||
|
||||
@@ -88,7 +91,6 @@ vi.mock("./audit-tool-policy.js", () => ({
|
||||
|
||||
describe("security audit install metadata findings", () => {
|
||||
let fixtureRoot = "";
|
||||
let sharedInstallMetadataStateDir = "";
|
||||
let caseId = 0;
|
||||
|
||||
const makeTmpDir = async (label: string) => {
|
||||
@@ -101,10 +103,50 @@ describe("security audit install metadata findings", () => {
|
||||
return await collectPluginsTrustFindings({ cfg, stateDir });
|
||||
};
|
||||
|
||||
const writePluginIndexInstallRecords = async (
|
||||
stateDir: string,
|
||||
records: Record<string, PluginInstallRecord>,
|
||||
) => {
|
||||
const index: InstalledPluginIndex = {
|
||||
version: 1,
|
||||
hostContractVersion: "2026.4.25",
|
||||
compatRegistryVersion: "compat",
|
||||
migrationVersion: 1,
|
||||
policyHash: "policy",
|
||||
generatedAtMs: Date.now(),
|
||||
plugins: Object.entries(records).map(([pluginId, installRecord]) => ({
|
||||
pluginId,
|
||||
installRecord,
|
||||
manifestPath: path.join(stateDir, "extensions", pluginId, "openclaw.plugin.json"),
|
||||
manifestHash: "manifest",
|
||||
rootDir: path.join(stateDir, "extensions", pluginId),
|
||||
origin: "global" as const,
|
||||
enabled: true,
|
||||
contributions: {
|
||||
providers: [],
|
||||
channels: [],
|
||||
channelConfigs: [],
|
||||
setupProviders: [],
|
||||
cliBackends: [],
|
||||
modelCatalogProviders: [],
|
||||
commandAliases: [],
|
||||
contracts: [],
|
||||
},
|
||||
startup: {
|
||||
sidecar: true,
|
||||
memory: false,
|
||||
deferConfiguredChannelFullLoadUntilAfterListen: false,
|
||||
agentHarnesses: [],
|
||||
},
|
||||
compat: [],
|
||||
})),
|
||||
diagnostics: [],
|
||||
};
|
||||
await writePersistedInstalledPluginIndex(index, { stateDir });
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-security-install-"));
|
||||
sharedInstallMetadataStateDir = path.join(fixtureRoot, "shared-install-metadata-state");
|
||||
await fs.mkdir(sharedInstallMetadataStateDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -122,17 +164,16 @@ describe("security audit install metadata findings", () => {
|
||||
}> = [
|
||||
{
|
||||
name: "warns on unpinned npm install specs and missing integrity metadata",
|
||||
run: async () =>
|
||||
runInstallMetadataAudit(
|
||||
run: async () => {
|
||||
const stateDir = await makeTmpDir("unpinned-plugin-index");
|
||||
await writePluginIndexInstallRecords(stateDir, {
|
||||
"voice-call": {
|
||||
source: "npm",
|
||||
spec: "@openclaw/voice-call",
|
||||
},
|
||||
});
|
||||
return runInstallMetadataAudit(
|
||||
{
|
||||
plugins: {
|
||||
installs: {
|
||||
"voice-call": {
|
||||
source: "npm",
|
||||
spec: "@openclaw/voice-call",
|
||||
},
|
||||
},
|
||||
},
|
||||
hooks: {
|
||||
internal: {
|
||||
installs: {
|
||||
@@ -144,29 +185,29 @@ describe("security audit install metadata findings", () => {
|
||||
},
|
||||
},
|
||||
},
|
||||
sharedInstallMetadataStateDir,
|
||||
),
|
||||
stateDir,
|
||||
);
|
||||
},
|
||||
expectedPresent: [
|
||||
"plugins.installs_unpinned_npm_specs",
|
||||
"plugins.installs_missing_integrity",
|
||||
"plugins.index_unpinned_npm_specs",
|
||||
"plugins.index_missing_integrity",
|
||||
"hooks.installs_unpinned_npm_specs",
|
||||
"hooks.installs_missing_integrity",
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "does not warn on pinned npm install specs with integrity metadata",
|
||||
run: async () =>
|
||||
runInstallMetadataAudit(
|
||||
run: async () => {
|
||||
const stateDir = await makeTmpDir("pinned-plugin-index");
|
||||
await writePluginIndexInstallRecords(stateDir, {
|
||||
"voice-call": {
|
||||
source: "npm",
|
||||
spec: "@openclaw/voice-call@1.2.3",
|
||||
integrity: "sha512-plugin",
|
||||
},
|
||||
});
|
||||
return runInstallMetadataAudit(
|
||||
{
|
||||
plugins: {
|
||||
installs: {
|
||||
"voice-call": {
|
||||
source: "npm",
|
||||
spec: "@openclaw/voice-call@1.2.3",
|
||||
integrity: "sha512-plugin",
|
||||
},
|
||||
},
|
||||
},
|
||||
hooks: {
|
||||
internal: {
|
||||
installs: {
|
||||
@@ -179,30 +220,30 @@ describe("security audit install metadata findings", () => {
|
||||
},
|
||||
},
|
||||
},
|
||||
sharedInstallMetadataStateDir,
|
||||
),
|
||||
stateDir,
|
||||
);
|
||||
},
|
||||
expectedAbsent: [
|
||||
"plugins.installs_unpinned_npm_specs",
|
||||
"plugins.installs_missing_integrity",
|
||||
"plugins.index_unpinned_npm_specs",
|
||||
"plugins.index_missing_integrity",
|
||||
"hooks.installs_unpinned_npm_specs",
|
||||
"hooks.installs_missing_integrity",
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "warns when install records drift from installed package versions",
|
||||
run: async () =>
|
||||
runInstallMetadataAudit(
|
||||
run: async () => {
|
||||
const stateDir = await makeTmpDir("drift-plugin-index");
|
||||
await writePluginIndexInstallRecords(stateDir, {
|
||||
"voice-call": {
|
||||
source: "npm",
|
||||
spec: "@openclaw/voice-call@1.2.3",
|
||||
integrity: "sha512-plugin",
|
||||
resolvedVersion: "1.2.3",
|
||||
},
|
||||
});
|
||||
return runInstallMetadataAudit(
|
||||
{
|
||||
plugins: {
|
||||
installs: {
|
||||
"voice-call": {
|
||||
source: "npm",
|
||||
spec: "@openclaw/voice-call@1.2.3",
|
||||
integrity: "sha512-plugin",
|
||||
resolvedVersion: "1.2.3",
|
||||
},
|
||||
},
|
||||
},
|
||||
hooks: {
|
||||
internal: {
|
||||
installs: {
|
||||
@@ -216,9 +257,10 @@ describe("security audit install metadata findings", () => {
|
||||
},
|
||||
},
|
||||
},
|
||||
sharedInstallMetadataStateDir,
|
||||
),
|
||||
expectedPresent: ["plugins.installs_version_drift", "hooks.installs_version_drift"],
|
||||
stateDir,
|
||||
);
|
||||
},
|
||||
expectedPresent: ["plugins.index_version_drift", "hooks.installs_version_drift"],
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { OpenClawConfig } from "../config/config.js";
|
||||
import type { AgentToolsConfig } from "../config/types.tools.js";
|
||||
import { readInstalledPackageVersion } from "../infra/package-update-utils.js";
|
||||
import { normalizePluginId, normalizePluginsConfig } from "../plugins/config-state.js";
|
||||
import { loadPluginInstallRecords } from "../plugins/install-ledger-store.js";
|
||||
import { loadInstalledPluginIndexInstallRecords } from "../plugins/installed-plugin-index-records.js";
|
||||
import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js";
|
||||
import type { SecurityAuditFinding } from "./audit.types.js";
|
||||
|
||||
@@ -421,7 +421,9 @@ export async function collectPluginsTrustFindings(params: {
|
||||
}
|
||||
}
|
||||
|
||||
const pluginInstalls = await loadPluginInstallRecords({ config: params.cfg });
|
||||
const pluginInstalls = await loadInstalledPluginIndexInstallRecords({
|
||||
stateDir: params.stateDir,
|
||||
});
|
||||
const npmPluginInstalls = Object.entries(pluginInstalls).filter(
|
||||
([, record]) => record?.source === "npm",
|
||||
);
|
||||
@@ -431,12 +433,10 @@ export async function collectPluginsTrustFindings(params: {
|
||||
.map(([pluginId, record]) => `${pluginId} (${record.spec})`);
|
||||
if (unpinned.length > 0) {
|
||||
findings.push({
|
||||
// Keep the legacy checkId stable for downstream audit consumers while
|
||||
// plugin install metadata moves from config to the managed ledger.
|
||||
checkId: "plugins.installs_unpinned_npm_specs",
|
||||
checkId: "plugins.index_unpinned_npm_specs",
|
||||
severity: "warn",
|
||||
title: "Plugin install ledger includes unpinned npm specs",
|
||||
detail: `Unpinned plugin install ledger records:\n${unpinned.map((entry) => `- ${entry}`).join("\n")}`,
|
||||
title: "Plugin index includes unpinned npm specs",
|
||||
detail: `Unpinned plugin index install records:\n${unpinned.map((entry) => `- ${entry}`).join("\n")}`,
|
||||
remediation:
|
||||
"Pin install specs to exact versions (for example, `@scope/pkg@1.2.3`) for higher supply-chain stability.",
|
||||
});
|
||||
@@ -449,10 +449,10 @@ export async function collectPluginsTrustFindings(params: {
|
||||
.map(([pluginId]) => pluginId);
|
||||
if (missingIntegrity.length > 0) {
|
||||
findings.push({
|
||||
checkId: "plugins.installs_missing_integrity",
|
||||
checkId: "plugins.index_missing_integrity",
|
||||
severity: "warn",
|
||||
title: "Plugin install ledger is missing integrity metadata",
|
||||
detail: `Plugin install ledger records missing integrity:\n${missingIntegrity.map((entry) => `- ${entry}`).join("\n")}`,
|
||||
title: "Plugin index is missing integrity metadata",
|
||||
detail: `Plugin index records missing integrity:\n${missingIntegrity.map((entry) => `- ${entry}`).join("\n")}`,
|
||||
remediation:
|
||||
"Reinstall or update plugins to refresh install metadata with resolved integrity hashes.",
|
||||
});
|
||||
@@ -475,9 +475,9 @@ export async function collectPluginsTrustFindings(params: {
|
||||
}
|
||||
if (pluginVersionDrift.length > 0) {
|
||||
findings.push({
|
||||
checkId: "plugins.installs_version_drift",
|
||||
checkId: "plugins.index_version_drift",
|
||||
severity: "warn",
|
||||
title: "Plugin install ledger records drift from installed package versions",
|
||||
title: "Plugin index records drift from installed package versions",
|
||||
detail: `Detected plugin install metadata drift:\n${pluginVersionDrift.map((entry) => `- ${entry}`).join("\n")}`,
|
||||
remediation:
|
||||
"Run `openclaw plugins update --all` (or reinstall affected plugins) to refresh install metadata.",
|
||||
|
||||
Reference in New Issue
Block a user