diff --git a/src/auto-reply/reply/commands-plugins.test.ts b/src/auto-reply/reply/commands-plugins.test.ts index 42f8fc5938c..8c6ff96a82b 100644 --- a/src/auto-reply/reply/commands-plugins.test.ts +++ b/src/auto-reply/reply/commands-plugins.test.ts @@ -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", () => ({ diff --git a/src/auto-reply/reply/commands-plugins.ts b/src/auto-reply/reply/commands-plugins.ts index 0928daf99bb..71eb8278247 100644 --- a/src/auto-reply/reply/commands-plugins.ts +++ b/src/auto-reply/reply/commands-plugins.ts @@ -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, diff --git a/src/cli/plugins-cli-test-helpers.ts b/src/cli/plugins-cli-test-helpers.ts index 1979e6b1078..1cad798baf8 100644 --- a/src/cli/plugins-cli-test-helpers.ts +++ b/src/cli/plugins-cli-test-helpers.ts @@ -33,11 +33,15 @@ export const listMarketplacePlugins: Mock = vi.fn(); export const resolveMarketplaceInstallShortcut: Mock = 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(); +vi.mock("../plugins/installed-plugin-index-records.js", async (importOriginal) => { + const actual = + await importOriginal(); return { ...actual, - loadPluginInstallRecords: ((...args: unknown[]) => - invokeMock(loadPluginInstallRecords, ...args)) as ( - ...args: unknown[] - ) => unknown, - writePersistedPluginInstallLedger: ((...args: unknown[]) => - invokeMock(writePersistedPluginInstallLedger, ...args)) as ( + loadInstalledPluginIndexInstallRecords: ((...args: unknown[]) => + invokeMock(loadInstalledPluginIndexInstallRecords, ...args)) as ( ...args: unknown[] ) => unknown, + writePersistedInstalledPluginIndexInstallRecords: ((...args: unknown[]) => + invokeMock( + writePersistedInstalledPluginIndexInstallRecords, + ...args, + )) as (...args: unknown[]) => unknown, recordPluginInstallInRecords: ( records: Record, update: { pluginId: string; installedAt?: string } & Record, @@ -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: [], diff --git a/src/cli/plugins-cli.install.test.ts b/src/cli/plugins-cli.install.test.ts index fff5e2a5e69..f8442141f3d 100644 --- a/src/cli/plugins-cli.install.test.ts +++ b/src/cli/plugins-cli.install.test.ts @@ -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", diff --git a/src/cli/plugins-cli.ts b/src/cli/plugins-cli.ts index f65235469ea..64c1140ded8 100644 --- a/src/cli/plugins-cli.ts +++ b/src/cli/plugins-cli.ts @@ -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({ diff --git a/src/cli/plugins-cli.uninstall.test.ts b/src/cli/plugins-cli.uninstall.test.ts index 1025df68aef..6311d86ff21 100644 --- a/src/cli/plugins-cli.uninstall.test.ts +++ b/src/cli/plugins-cli.uninstall.test.ts @@ -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: {}, diff --git a/src/cli/plugins-cli.update.test.ts b/src/cli/plugins-cli.update.test.ts index 17c90701369..019c0ddfba6 100644 --- a/src/cli/plugins-cli.update.test.ts +++ b/src/cli/plugins-cli.update.test.ts @@ -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: {}, diff --git a/src/cli/plugins-install-persist.test.ts b/src/cli/plugins-install-persist.test.ts index b228ceb2fe9..c67c9538d1a 100644 --- a/src/cli/plugins-install-persist.test.ts +++ b/src/cli/plugins-install-persist.test.ts @@ -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", diff --git a/src/cli/plugins-install-persist.ts b/src/cli/plugins-install-persist.ts index b5939938c1e..fd96b240076 100644 --- a/src/cli/plugins-install-persist.ts +++ b/src/cli/plugins-install-persist.ts @@ -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)), }, diff --git a/src/cli/plugins-registry-refresh.ts b/src/cli/plugins-registry-refresh.ts index 67ebc37ad62..c0c9dc466e3 100644 --- a/src/cli/plugins-registry-refresh.ts +++ b/src/cli/plugins-registry-refresh.ts @@ -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>; logger?: PluginRegistryRefreshLogger; }): Promise { 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 } : {}), }); diff --git a/src/cli/plugins-update-command.ts b/src/cli/plugins-update-command.ts index 4babcc482fe..0e648d9e09d 100644 --- a/src/cli/plugins-update-command.ts +++ b/src/cli/plugins-update-command.ts @@ -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, }); } diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index 04345eae6fa..7dd4dc2aa3d 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -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(); +vi.mock("../plugins/installed-plugin-index-records.js", async (importOriginal) => { + const actual = + await importOriginal(); 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), }; }); diff --git a/src/cli/update-cli/update-command.ts b/src/cli/update-cli/update-command.ts index a6a076b6fbd..cdd68489465 100644 --- a/src/cli/update-cli/update-command.ts +++ b/src/cli/update-cli/update-command.ts @@ -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, diff --git a/src/commands/channels/add.ts b/src/commands/channels/add.ts index bf86af0751b..507512a2b9f 100644 --- a/src/commands/channels/add.ts +++ b/src/commands/channels/add.ts @@ -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, diff --git a/src/commands/channels/capabilities.ts b/src/commands/channels/capabilities.ts index a6a037aad93..c1bc327f6d3 100644 --- a/src/commands/channels/capabilities.ts +++ b/src/commands/channels/capabilities.ts @@ -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({ diff --git a/src/commands/channels/remove.ts b/src/commands/channels/remove.ts index 9486aa6ed82..6b57817c9cf 100644 --- a/src/commands/channels/remove.ts +++ b/src/commands/channels/remove.ts @@ -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({ diff --git a/src/commands/channels/resolve.ts b/src/commands/channels/resolve.ts index 2f2d8c58f42..884811475e3 100644 --- a/src/commands/channels/resolve.ts +++ b/src/commands/channels/resolve.ts @@ -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({ diff --git a/src/commands/models/list.provider-catalog.test.ts b/src/commands/models/list.provider-catalog.test.ts index 22709fc1371..32d99d73390 100644 --- a/src/commands/models/list.provider-catalog.test.ts +++ b/src/commands/models/list.provider-catalog.test.ts @@ -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"]); diff --git a/src/commands/onboarding-plugin-install.ts b/src/commands/onboarding-plugin-install.ts index b9cbab9ad77..380759f9ec2 100644 --- a/src/commands/onboarding-plugin-install.ts +++ b/src/commands/onboarding-plugin-install.ts @@ -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[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: { diff --git a/src/plugins/install-ledger-store.ts b/src/plugins/install-ledger-store.ts deleted file mode 100644 index e31a349186a..00000000000 --- a/src/plugins/install-ledger-store.ts +++ /dev/null @@ -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; -}; - -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 | undefined, -): Record { - 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 { - const parsed = await readJsonFile(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, - options: PluginInstallLedgerStoreOptions & { now?: () => Date } = {}, -): Promise { - 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> { - 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 { - const ledger = readPersistedPluginInstallLedgerSync(params); - if (ledger) { - return cloneInstallRecords(ledger.records); - } - return cloneInstallRecords(params.config?.plugins?.installs); -} - -export function withPluginInstallRecords( - config: OpenClawConfig, - records: Record, -): 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, - update: PluginInstallUpdate, -): Record { - return recordPluginInstall({ plugins: { installs: records } }, update).plugins?.installs ?? {}; -} - -export function removePluginInstallRecordFromRecords( - records: Record, - pluginId: string, -): Record { - const { [pluginId]: _removed, ...rest } = records; - return rest; -} diff --git a/src/plugins/install-ledger-store.test.ts b/src/plugins/installed-plugin-index-records.test.ts similarity index 52% rename from src/plugins/install-ledger-store.test.ts rename to src/plugins/installed-plugin-index-records.test.ts index 90ef1c97eb0..a6ad85a1bea 100644 --- a/src/plugins/install-ledger-store.test.ts +++ b/src/plugins/installed-plugin-index-records.test.ts @@ -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({}); }); }); diff --git a/src/plugins/installed-plugin-index-records.ts b/src/plugins/installed-plugin-index-records.ts new file mode 100644 index 00000000000..d9744620510 --- /dev/null +++ b/src/plugins/installed-plugin-index-records.ts @@ -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> & { + now?: () => Date; + }; + +function toInstallRecords( + index: Awaited>, +): Record | null { + if (!index) { + return null; + } + return extractPluginInstallRecordsFromInstalledPluginIndex(index); +} + +function cloneInstallRecords( + records: Record | undefined, +): Record { + return structuredClone(records ?? {}); +} + +export function resolveInstalledPluginIndexRecordsStorePath( + options: InstalledPluginIndexRecordStoreOptions = {}, +): string { + return resolveInstalledPluginIndexStorePath(options); +} + +export async function readPersistedInstalledPluginIndexInstallRecords( + options: InstalledPluginIndexRecordStoreOptions = {}, +): Promise | null> { + return toInstallRecords(await readPersistedInstalledPluginIndex(options)); +} + +export function readPersistedInstalledPluginIndexInstallRecordsSync( + options: InstalledPluginIndexRecordStoreOptions = {}, +): Record | null { + return toInstallRecords(readPersistedInstalledPluginIndexSync(options)); +} + +export async function writePersistedInstalledPluginIndexInstallRecords( + records: Record, + options: InstalledPluginIndexRecordRefreshOptions = {}, +): Promise { + await refreshPersistedInstalledPluginIndex({ + ...options, + reason: "source-changed", + installRecords: records, + }); + return resolveInstalledPluginIndexRecordsStorePath(options); +} + +export async function loadInstalledPluginIndexInstallRecords( + params: InstalledPluginIndexRecordStoreOptions = {}, +): Promise> { + return cloneInstallRecords((await readPersistedInstalledPluginIndexInstallRecords(params)) ?? {}); +} + +export function loadInstalledPluginIndexInstallRecordsSync( + params: InstalledPluginIndexRecordStoreOptions = {}, +): Record { + return cloneInstallRecords(readPersistedInstalledPluginIndexInstallRecordsSync(params) ?? {}); +} + +export function withPluginInstallRecords( + config: OpenClawConfig, + records: Record, +): 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, + update: PluginInstallUpdate, +): Record { + return recordPluginInstall({ plugins: { installs: records } }, update).plugins?.installs ?? {}; +} + +export function removePluginInstallRecordFromRecords( + records: Record, + pluginId: string, +): Record { + const { [pluginId]: _removed, ...rest } = records; + return rest; +} diff --git a/src/plugins/installed-plugin-index-store.test.ts b/src/plugins/installed-plugin-index-store.test.ts index 28b9a107a4e..4fd62fc4e05 100644 --- a/src/plugins/installed-plugin-index-store.test.ts +++ b/src/plugins/installed-plugin-index-store.test.ts @@ -27,7 +27,7 @@ function createIndex(overrides: Partial = {}): 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"), ); }); diff --git a/src/plugins/installed-plugin-index-store.ts b/src/plugins/installed-plugin-index-store.ts index 9fea8c2dc32..49784e3989d 100644 --- a/src/plugins/installed-plugin-index-store.ts +++ b/src/plugins/installed-plugin-index-store.ts @@ -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 { const persisted = await readPersistedInstalledPluginIndex(params); - const current = loadInstalledPluginIndex(params); + const current = loadInstalledPluginIndex({ + ...params, + installRecords: + params.installRecords ?? extractPluginInstallRecordsFromInstalledPluginIndex(persisted), + }); if (!persisted) { return { state: "missing", diff --git a/src/plugins/installed-plugin-index.test.ts b/src/plugins/installed-plugin-index.test.ts index cc5bbf11c72..9518c3afc81 100644 --- a/src/plugins/installed-plugin-index.test.ts +++ b/src/plugins/installed-plugin-index.test.ts @@ -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([ diff --git a/src/plugins/installed-plugin-index.ts b/src/plugins/installed-plugin-index.ts index 03ab686ff3b..e9a5c12f238 100644 --- a/src/plugins/installed-plugin-index.ts +++ b/src/plugins/installed-plugin-index.ts @@ -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; 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 { + const records: Record = {}; + 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 { @@ -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); diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 4b09f2e5a3f..ec682675ace 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -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(); - 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, diff --git a/src/plugins/manifest-registry.ts b/src/plugins/manifest-registry.ts index 1fcb4ee6ffd..75635c6e1eb 100644 --- a/src/plugins/manifest-registry.ts +++ b/src/plugins/manifest-registry.ts @@ -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; } diff --git a/src/plugins/plugin-registry.test.ts b/src/plugins/plugin-registry.test.ts index a26bbd8be38..0d9b57a5640 100644 --- a/src/plugins/plugin-registry.test.ts +++ b/src/plugins/plugin-registry.test.ts @@ -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: [ diff --git a/src/plugins/plugin-registry.ts b/src/plugins/plugin-registry.ts index beb32f06c17..1b1313daedf 100644 --- a/src/plugins/plugin-registry.ts +++ b/src/plugins/plugin-registry.ts @@ -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, }; diff --git a/src/plugins/update.test.ts b/src/plugins/update.test.ts index 1c665d5a37f..5514567655c 100644 --- a/src/plugins/update.test.ts +++ b/src/plugins/update.test.ts @@ -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({ diff --git a/src/secrets/runtime-web-tools.test.ts b/src/secrets/runtime-web-tools.test.ts index 427998702f5..dce54b6aa68 100644 --- a/src/secrets/runtime-web-tools.test.ts +++ b/src/secrets/runtime-web-tools.test.ts @@ -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( - "../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", diff --git a/src/secrets/runtime-web-tools.ts b/src/secrets/runtime-web-tools.ts index 227a149f7f8..6378d5f2777 100644 --- a/src/secrets/runtime-web-tools.ts +++ b/src/secrets/runtime-web-tools.ts @@ -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 { - const installRecords = loadPluginInstallRecordsSync({ - config: params.config, - env: params.env, - }); + const installRecords = loadInstalledPluginIndexInstallRecordsSync({ env: params.env }); if (Object.keys(installRecords).length > 0) { return true; } diff --git a/src/security/audit-plugins-trust.test.ts b/src/security/audit-plugins-trust.test.ts index f82ebef6688..02bb5c4c64a 100644 --- a/src/security/audit-plugins-trust.test.ts +++ b/src/security/audit-plugins-trust.test.ts @@ -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, + ) => { + 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"], }, ]; diff --git a/src/security/audit-plugins-trust.ts b/src/security/audit-plugins-trust.ts index ab22d25e322..3a4e090f44a 100644 --- a/src/security/audit-plugins-trust.ts +++ b/src/security/audit-plugins-trust.ts @@ -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.",