From 888448facc1b9d1b65d8da83d734190dcbd7d1ba Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sat, 25 Apr 2026 11:36:42 -0700 Subject: [PATCH] feat(plugins): move install records to managed ledger --- CHANGELOG.md | 3 + docs/.generated/config-baseline.sha256 | 4 +- docs/cli/plugins.md | 23 +- docs/gateway/configuration-reference.md | 11 +- docs/plugins/architecture-internals.md | 8 +- src/auto-reply/reply/commands-plugins.test.ts | 4 + src/auto-reply/reply/commands-plugins.ts | 14 +- src/cli/plugins-cli-test-helpers.ts | 41 ++++ src/cli/plugins-cli.ts | 27 ++- src/cli/plugins-cli.uninstall.test.ts | 14 +- src/cli/plugins-cli.update.test.ts | 6 +- src/cli/plugins-install-persist.test.ts | 29 +-- src/cli/plugins-install-persist.ts | 16 +- src/cli/plugins-update-command.ts | 29 ++- src/cli/update-cli.test.ts | 9 + src/cli/update-cli/update-command.ts | 19 +- src/commands/channels/add.ts | 35 +++- src/commands/channels/capabilities.ts | 15 ++ src/commands/channels/remove.ts | 15 ++ src/commands/channels/resolve.ts | 15 ++ .../shared/plugin-registry-migration.ts | 8 + src/commands/onboarding-plugin-install.ts | 35 +++- src/config/schema.base.generated.ts | 4 +- src/config/schema.help.ts | 2 +- src/plugins/compat/registry.ts | 20 ++ src/plugins/install-ledger-store.test.ts | 197 ++++++++++++++++++ src/plugins/install-ledger-store.ts | 162 ++++++++++++++ src/plugins/installed-plugin-index.ts | 12 +- src/plugins/loader.ts | 9 +- src/plugins/manifest-registry.ts | 6 +- src/security/audit-plugins-trust.ts | 3 +- 31 files changed, 721 insertions(+), 74 deletions(-) create mode 100644 src/plugins/install-ledger-store.test.ts create mode 100644 src/plugins/install-ledger-store.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d82c2d4389..2edf880b6c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,9 @@ Docs: https://docs.openclaw.ai - Diagnostics/OTEL: export existing tool-loop diagnostics as `openclaw.tool.loop` counters and spans without loop messages, session identifiers, params, or tool output. Thanks @vincentkoc. - Diagnostics/OTEL: export diagnostic memory samples and pressure as bounded memory histograms, counters, and pressure spans to help spot leak regressions without session or payload data. Thanks @vincentkoc. - Diagnostics/OTEL: add the GenAI `gen_ai.client.token.usage` histogram for input/output model usage while keeping session identifiers and aggregate cache counters out of the semantic metric. Thanks @vincentkoc. +- Plugins/install: move managed plugin install metadata from `plugins.installs` + to the state-managed `plugins/installs.json` ledger, with legacy config reads + kept as a deprecated compatibility fallback. Thanks @vincentkoc. - Diagnostics/OTEL: add bounded outbound message delivery lifecycle diagnostics and export them as low-cardinality delivery spans/metrics without message body, recipient, room, or media-path data. (#71471) Thanks @vincentkoc and @jlapenna. - Diagnostics/OTEL: emit bounded exec-process diagnostics and export them as `openclaw.exec` spans without exposing command text, working directories, or container identifiers. (#71451) Thanks @vincentkoc and @jlapenna. - Diagnostics/OTEL: support `OPENCLAW_OTEL_PRELOADED=1` so the plugin can reuse an already-registered OpenTelemetry SDK while keeping OpenClaw diagnostic listeners wired. (#71450) Thanks @vincentkoc and @jlapenna. diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index 5ea41f5501d..0aafcd4e19f 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -1,4 +1,4 @@ -439ff58a4a54f0f4bda959239f382cc3b2f94a282680dcd89bd3f8c93e0f07d0 config-baseline.json -6ef86147534d12aa5ac7a9cf208b4627177090c92479a71dfd1791096d20353b config-baseline.core.json +c8d24c55df89a76f44cd6ab5fdb7c28b0b3a8adadcd2c94a1d81263512075c0f config-baseline.json +97c37380e03c167ee710adb0ee297573146e78434635780226b744841628370b config-baseline.core.json 7cd9c908f066c143eab2a201efbc9640f483ab28bba92ddeca1d18cc2b528bc3 config-baseline.channel.json 7825b56a5b3fcdbe2e09ef8fe5d9f12ac3598435afebe20413051e45b0d1968e config-baseline.plugin.json diff --git a/docs/cli/plugins.md b/docs/cli/plugins.md index 9e4406bf334..c9637730c3e 100644 --- a/docs/cli/plugins.md +++ b/docs/cli/plugins.md @@ -231,7 +231,19 @@ openclaw plugins install -l ./my-plugin source path instead of copying over a managed install target. Use `--pin` on npm installs to save the resolved exact spec (`name@version`) in -`plugins.installs` while keeping the default behavior unpinned. +the managed install ledger while keeping the default behavior unpinned. + +### Install Ledger + +Plugin install metadata is machine-managed state, not user config. New installs +and updates write it to `plugins/installs.json` under the active OpenClaw state +directory. The file includes a do-not-edit warning and is used by +`openclaw plugins update`, uninstall, diagnostics, and the cold plugin registry. + +Legacy `plugins.installs` entries in `openclaw.json` remain readable as a +deprecated compatibility fallback. When install/update/uninstall paths rewrite +plugin install state, OpenClaw writes the ledger file and removes +`plugins.installs` from the persisted config payload. ### Uninstall @@ -241,8 +253,9 @@ openclaw plugins uninstall --dry-run openclaw plugins uninstall --keep-files ``` -`uninstall` removes plugin records from `plugins.entries`, `plugins.installs`, -the plugin allowlist, and linked `plugins.load.paths` entries when applicable. +`uninstall` removes plugin records from `plugins.entries`, the managed install +ledger, the plugin allowlist, and linked `plugins.load.paths` entries when +applicable. For active memory plugins, the memory slot resets to `memory-core`. By default, uninstall also removes the plugin install directory under the active @@ -261,8 +274,8 @@ openclaw plugins update @openclaw/voice-call@beta openclaw plugins update openclaw-codex-app-server --dangerously-force-unsafe-install ``` -Updates apply to tracked installs in `plugins.installs` and tracked hook-pack -installs in `hooks.internal.installs`. +Updates apply to tracked plugin installs in the managed install ledger and +tracked hook-pack installs in `hooks.internal.installs`. When you pass a plugin id, OpenClaw reuses the recorded install spec for that plugin. That means previously stored dist-tags such as `@beta` and exact pinned diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index ec916e481b2..614ea8ae3f6 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -186,9 +186,14 @@ See [MCP](/cli/mcp#openclaw-as-an-mcp-client-registry) and - Enabled Claude bundle plugins can also contribute embedded Pi defaults from `settings.json`; OpenClaw applies those as sanitized agent settings, not as raw OpenClaw config patches. - `plugins.slots.memory`: pick the active memory plugin id, or `"none"` to disable memory plugins. - `plugins.slots.contextEngine`: pick the active context engine plugin id; defaults to `"legacy"` unless you install and select another engine. -- `plugins.installs`: CLI-managed install metadata used by `openclaw plugins update`. - - Includes `source`, `spec`, `sourcePath`, `installPath`, `version`, `resolvedName`, `resolvedVersion`, `resolvedSpec`, `integrity`, `shasum`, `resolvedAt`, `installedAt`. - - Treat `plugins.installs.*` as managed state; prefer CLI commands over manual edits. +- `plugins.installs`: deprecated compatibility fallback for legacy + CLI-managed install metadata. New plugin installs write the managed + `plugins/installs.json` state ledger instead. + - Legacy records include `source`, `spec`, `sourcePath`, `installPath`, + `version`, `resolvedName`, `resolvedVersion`, `resolvedSpec`, `integrity`, + `shasum`, `resolvedAt`, `installedAt`. + - Treat `plugins.installs.*` as managed state; prefer CLI commands over + manual edits. See [Plugins](/tools/plugin). diff --git a/docs/plugins/architecture-internals.md b/docs/plugins/architecture-internals.md index 5d2895ba6a6..203e8a9d960 100644 --- a/docs/plugins/architecture-internals.md +++ b/docs/plugins/architecture-internals.md @@ -911,13 +911,15 @@ Official external npm entries should prefer an exact `npmSpec` plus `expectedIntegrity`. Bare package names and dist-tags still work for compatibility, but they surface source-plane warnings so the catalog can move toward pinned, integrity-checked installs without breaking existing plugins. -When onboarding installs from a local catalog path, it records a -`plugins.installs` entry with `source: "path"` and a workspace-relative +When onboarding installs from a local catalog path, it records a managed plugin +install ledger entry with `source: "path"` and a workspace-relative `sourcePath` when possible. The absolute operational load path stays in `plugins.load.paths`; the install record avoids duplicating local workstation paths into long-lived config. This keeps local development installs visible to source-plane diagnostics without adding a second raw filesystem-path disclosure -surface. +surface. Legacy `plugins.installs` config entries are still read as a +compatibility fallback while the state-managed `plugins/installs.json` ledger +becomes the install source of truth. ## Context engine plugins diff --git a/src/auto-reply/reply/commands-plugins.test.ts b/src/auto-reply/reply/commands-plugins.test.ts index c298d7d06cf..42f8fc5938c 100644 --- a/src/auto-reply/reply/commands-plugins.test.ts +++ b/src/auto-reply/reply/commands-plugins.test.ts @@ -55,6 +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/manifest-registry.js", () => ({ clearPluginManifestRegistryCache: vi.fn(), })); diff --git a/src/auto-reply/reply/commands-plugins.ts b/src/auto-reply/reply/commands-plugins.ts index adc67152c06..0928daf99bb 100644 --- a/src/auto-reply/reply/commands-plugins.ts +++ b/src/auto-reply/reply/commands-plugins.ts @@ -18,6 +18,7 @@ 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 { clearPluginManifestRegistryCache } from "../../plugins/manifest-registry.js"; import type { PluginRecord } from "../../plugins/registry.js"; @@ -49,6 +50,7 @@ function renderJsonBlock(label: string, value: unknown): string { function buildPluginInspectJson(params: { id: string; config: OpenClawConfig; + installRecords: Record; report: PluginStatusReport; }): { inspect: NonNullable>; @@ -74,12 +76,13 @@ function buildPluginInspectJson(params: { severity: warning.severity, message: formatPluginCompatibilityNotice(warning), })), - install: params.config.plugins?.installs?.[inspect.plugin.id] ?? null, + install: params.installRecords[inspect.plugin.id] ?? null, }; } function buildAllPluginInspectJson(params: { config: OpenClawConfig; + installRecords: Record; report: PluginStatusReport; }): Array<{ inspect: ReturnType[number]; @@ -100,7 +103,7 @@ function buildAllPluginInspectJson(params: { severity: warning.severity, message: formatPluginCompatibilityNotice(warning), })), - install: params.config.plugins?.installs?.[inspect.plugin.id] ?? null, + install: params.installRecords[inspect.plugin.id] ?? null, })); } @@ -413,6 +416,7 @@ export const handlePluginsCommand: CommandHandler = async (params, allowTextComm } if (pluginsCommand.action === "inspect") { + const installRecords = await loadPluginInstallRecords({ config: loaded.config }); if (!pluginsCommand.name) { return { shouldContinue: false, @@ -423,13 +427,17 @@ export const handlePluginsCommand: CommandHandler = async (params, allowTextComm return { shouldContinue: false, reply: { - text: renderJsonBlock("🔌 Plugins", buildAllPluginInspectJson(loaded)), + text: renderJsonBlock( + "🔌 Plugins", + buildAllPluginInspectJson({ ...loaded, installRecords }), + ), }, }; } const payload = buildPluginInspectJson({ id: pluginsCommand.name, config: loaded.config, + installRecords, report: loaded.report, }); if (!payload) { diff --git a/src/cli/plugins-cli-test-helpers.ts b/src/cli/plugins-cli-test-helpers.ts index f7dfbf5d71f..a5bfcf5bcfa 100644 --- a/src/cli/plugins-cli-test-helpers.ts +++ b/src/cli/plugins-cli-test-helpers.ts @@ -32,6 +32,11 @@ 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 ({ config }) => { + const cfg = config as OpenClawConfig | undefined; + return structuredClone(cfg?.plugins?.installs ?? {}); +}); +export const writePersistedPluginInstallLedger: AsyncUnknownMock = vi.fn(async () => undefined); export const clearPluginManifestRegistryCache: UnknownMock = vi.fn(); export const loadPluginManifestRegistry: UnknownMock = vi.fn(); export const buildPluginSnapshotReport: UnknownMock = vi.fn(); @@ -151,6 +156,35 @@ 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(); + return { + ...actual, + loadPluginInstallRecords: ((...args: unknown[]) => + invokeMock(loadPluginInstallRecords, ...args)) as ( + ...args: unknown[] + ) => unknown, + writePersistedPluginInstallLedger: ((...args: unknown[]) => + invokeMock(writePersistedPluginInstallLedger, ...args)) as ( + ...args: unknown[] + ) => unknown, + recordPluginInstallInRecords: ( + records: Record, + update: { pluginId: string; installedAt?: string } & Record, + ) => { + const { pluginId, ...record } = update; + return { + ...records, + [pluginId]: { + ...(records[pluginId] as Record | undefined), + ...record, + installedAt: update.installedAt ?? "2026-04-25T00:00:00.000Z", + }, + }; + }, + }; +}); + vi.mock("../plugins/manifest-registry.js", () => ({ clearPluginManifestRegistryCache: () => clearPluginManifestRegistryCache(), loadPluginManifestRegistry: ((...args: unknown[]) => @@ -424,6 +458,8 @@ export function resetPluginsCliTestState() { resolveMarketplaceInstallShortcut.mockReset(); enablePluginInConfig.mockReset(); recordPluginInstall.mockReset(); + loadPluginInstallRecords.mockReset(); + writePersistedPluginInstallLedger.mockReset(); clearPluginManifestRegistryCache.mockReset(); loadPluginManifestRegistry.mockReset(); buildPluginSnapshotReport.mockReset(); @@ -482,6 +518,11 @@ export function resetPluginsCliTestState() { recordPluginInstall.mockImplementation( ((cfg: OpenClawConfig) => cfg) as (...args: unknown[]) => unknown, ); + loadPluginInstallRecords.mockImplementation(async ({ config }) => { + const cfg = config as OpenClawConfig | undefined; + return structuredClone(cfg?.plugins?.installs ?? {}); + }); + writePersistedPluginInstallLedger.mockResolvedValue(undefined); loadPluginManifestRegistry.mockReturnValue({ plugins: [], diagnostics: [], diff --git a/src/cli/plugins-cli.ts b/src/cli/plugins-cli.ts index 7350f6a318f..f65235469ea 100644 --- a/src/cli/plugins-cli.ts +++ b/src/cli/plugins-cli.ts @@ -6,6 +6,14 @@ import { resolveStateDir } from "../config/paths.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { PluginInstallRecord } from "../config/types.plugins.js"; import { enablePluginInConfig } from "../plugins/enable.js"; +import { + loadPluginInstallRecords, + PLUGIN_INSTALLS_CONFIG_PATH, + removePluginInstallRecordFromRecords, + withoutPluginInstallRecords, + writePersistedPluginInstallLedger, + withPluginInstallRecords, +} from "../plugins/install-ledger-store.js"; import { listMarketplacePlugins } from "../plugins/marketplace.js"; import { inspectPluginRegistry, refreshPluginRegistry } from "../plugins/plugin-registry.js"; import { defaultSlotIdForKey } from "../plugins/slots.js"; @@ -280,8 +288,9 @@ export function registerPluginsCli(program: Command) { .argument("[id]", "Plugin id") .option("--all", "Inspect all plugins") .option("--json", "Print JSON") - .action((id: string | undefined, opts: PluginInspectOptions) => { + .action(async (id: string | undefined, opts: PluginInspectOptions) => { const cfg = loadConfig(); + const installRecords = await loadPluginInstallRecords({ config: cfg }); const report = buildPluginDiagnosticsReport({ config: cfg, ...(opts.json ? { logger: quietPluginJsonLogger } : {}), @@ -298,7 +307,7 @@ export function registerPluginsCli(program: Command) { }); const inspectAllWithInstall = inspectAll.map((inspect) => ({ ...inspect, - install: cfg.plugins?.installs?.[inspect.plugin.id], + install: installRecords[inspect.plugin.id], })); if (opts.json) { @@ -369,7 +378,7 @@ export function registerPluginsCli(program: Command) { defaultRuntime.error(`Plugin not found: ${id}`); return defaultRuntime.exit(1); } - const install = cfg.plugins?.installs?.[inspect.plugin.id]; + const install = installRecords[inspect.plugin.id]; if (opts.json) { defaultRuntime.writeJson({ @@ -574,7 +583,9 @@ export function registerPluginsCli(program: Command) { .option("--dry-run", "Show what would be removed without making changes", false) .action(async (id: string, opts: PluginUninstallOptions) => { const snapshot = await readConfigFileSnapshot(); - const cfg = (snapshot.sourceConfig ?? snapshot.config) as OpenClawConfig; + const sourceConfig = (snapshot.sourceConfig ?? snapshot.config) as OpenClawConfig; + const installRecords = await loadPluginInstallRecords({ config: sourceConfig }); + const cfg = withPluginInstallRecords(sourceConfig, installRecords); const report = buildPluginDiagnosticsReport({ config: cfg }); const extensionsDir = path.join(resolveStateDir(process.env, os.homedir), "extensions"); const keepFiles = Boolean(opts.keepFiles || opts.keepConfig); @@ -680,12 +691,16 @@ export function registerPluginsCli(program: Command) { defaultRuntime.log(theme.warn(warning)); } + await writePersistedPluginInstallLedger( + removePluginInstallRecordFromRecords(installRecords, pluginId), + ); await replaceConfigFile({ - nextConfig: result.config, + nextConfig: withoutPluginInstallRecords(result.config), ...(snapshot.hash !== undefined ? { baseHash: snapshot.hash } : {}), + writeOptions: { unsetPaths: [Array.from(PLUGIN_INSTALLS_CONFIG_PATH)] }, }); await refreshPluginRegistryAfterConfigMutation({ - config: result.config, + config: withoutPluginInstallRecords(result.config), reason: "source-changed", logger: { warn: (message) => defaultRuntime.log(theme.warn(message)), diff --git a/src/cli/plugins-cli.uninstall.test.ts b/src/cli/plugins-cli.uninstall.test.ts index 6b4a2b3029a..1025df68aef 100644 --- a/src/cli/plugins-cli.uninstall.test.ts +++ b/src/cli/plugins-cli.uninstall.test.ts @@ -12,6 +12,7 @@ import { runtimeLogs, uninstallPlugin, writeConfigFile, + writePersistedPluginInstallLedger, } from "./plugins-cli-test-helpers.js"; const CLI_STATE_ROOT = "/tmp/openclaw-state"; @@ -102,9 +103,18 @@ describe("plugins cli uninstall", () => { deleteFiles: false, }), ); - expect(writeConfigFile).toHaveBeenCalledWith(nextConfig); + expect(writePersistedPluginInstallLedger).toHaveBeenCalledWith({}); + expect(writeConfigFile).toHaveBeenCalledWith({ + plugins: { + entries: {}, + }, + }); expect(refreshPluginRegistry).toHaveBeenCalledWith({ - config: nextConfig, + config: { + plugins: { + entries: {}, + }, + }, reason: "source-changed", }); }); diff --git a/src/cli/plugins-cli.update.test.ts b/src/cli/plugins-cli.update.test.ts index c9bf3a69737..17c90701369 100644 --- a/src/cli/plugins-cli.update.test.ts +++ b/src/cli/plugins-cli.update.test.ts @@ -12,6 +12,7 @@ import { updateNpmInstalledHookPacks, updateNpmInstalledPlugins, writeConfigFile, + writePersistedPluginInstallLedger, } from "./plugins-cli-test-helpers.js"; function createTrackedPluginConfig(params: { @@ -210,9 +211,10 @@ describe("plugins cli update", () => { dryRun: false, }), ); - expect(writeConfigFile).toHaveBeenCalledWith(nextConfig); + expect(writePersistedPluginInstallLedger).toHaveBeenCalledWith(nextConfig.plugins?.installs); + expect(writeConfigFile).toHaveBeenCalledWith({}); expect(refreshPluginRegistry).toHaveBeenCalledWith({ - config: nextConfig, + config: {}, reason: "source-changed", }); expect( diff --git a/src/cli/plugins-install-persist.test.ts b/src/cli/plugins-install-persist.test.ts index ce996fa9020..b228ceb2fe9 100644 --- a/src/cli/plugins-install-persist.test.ts +++ b/src/cli/plugins-install-persist.test.ts @@ -2,10 +2,10 @@ import { beforeEach, describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { enablePluginInConfig, - recordPluginInstall, refreshPluginRegistry, resetPluginsCliTestState, writeConfigFile, + writePersistedPluginInstallLedger, } from "./plugins-cli-test-helpers.js"; describe("persistPluginInstall", () => { @@ -28,26 +28,12 @@ describe("persistPluginInstall", () => { }, }, } as OpenClawConfig; - const persistedConfig = { - plugins: { - ...enabledConfig.plugins, - installs: { - alpha: { - source: "npm", - spec: "alpha@1.0.0", - installPath: "/tmp/alpha", - }, - }, - }, - } as OpenClawConfig; - enablePluginInConfig.mockImplementation((...args: unknown[]) => { const [cfg, pluginId] = args as [OpenClawConfig, string]; expect(pluginId).toBe("alpha"); expect(cfg.plugins?.allow).toEqual(["alpha", "memory-core"]); return { config: enabledConfig }; }); - recordPluginInstall.mockReturnValue(persistedConfig); const next = await persistPluginInstall({ config: baseConfig, @@ -59,10 +45,17 @@ describe("persistPluginInstall", () => { }, }); - expect(next).toBe(persistedConfig); - expect(writeConfigFile).toHaveBeenCalledWith(persistedConfig); + expect(next).toEqual(enabledConfig); + expect(writePersistedPluginInstallLedger).toHaveBeenCalledWith({ + alpha: expect.objectContaining({ + source: "npm", + spec: "alpha@1.0.0", + installPath: "/tmp/alpha", + }), + }); + expect(writeConfigFile).toHaveBeenCalledWith(enabledConfig); expect(refreshPluginRegistry).toHaveBeenCalledWith({ - config: persistedConfig, + config: enabledConfig, reason: "source-changed", }); }); diff --git a/src/cli/plugins-install-persist.ts b/src/cli/plugins-install-persist.ts index 0dca0408f1d..b5939938c1e 100644 --- a/src/cli/plugins-install-persist.ts +++ b/src/cli/plugins-install-persist.ts @@ -2,7 +2,14 @@ import { replaceConfigFile } from "../config/config.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { type HookInstallUpdate, recordHookInstall } from "../hooks/installs.js"; import { enablePluginInConfig } from "../plugins/enable.js"; -import { type PluginInstallUpdate, recordPluginInstall } from "../plugins/installs.js"; +import { + loadPluginInstallRecords, + PLUGIN_INSTALLS_CONFIG_PATH, + recordPluginInstallInRecords, + withoutPluginInstallRecords, + writePersistedPluginInstallLedger, +} from "../plugins/install-ledger-store.js"; +import type { PluginInstallUpdate } from "../plugins/installs.js"; import { defaultRuntime } from "../runtime.js"; import { theme } from "../terminal/theme.js"; import { @@ -39,15 +46,18 @@ export async function persistPluginInstall(params: { addInstalledPluginToAllowlist(params.config, params.pluginId), params.pluginId, ).config; - next = recordPluginInstall(next, { + const installRecords = await loadPluginInstallRecords({ config: params.config }); + const nextInstallRecords = recordPluginInstallInRecords(installRecords, { pluginId: params.pluginId, ...params.install, }); const slotResult = applySlotSelectionForPlugin(next, params.pluginId); - next = slotResult.config; + next = withoutPluginInstallRecords(slotResult.config); + await writePersistedPluginInstallLedger(nextInstallRecords); await replaceConfigFile({ nextConfig: next, ...(params.baseHash !== undefined ? { baseHash: params.baseHash } : {}), + writeOptions: { unsetPaths: [Array.from(PLUGIN_INSTALLS_CONFIG_PATH)] }, }); await refreshPluginRegistryAfterConfigMutation({ config: next, diff --git a/src/cli/plugins-update-command.ts b/src/cli/plugins-update-command.ts index aacdaf43f6a..4babcc482fe 100644 --- a/src/cli/plugins-update-command.ts +++ b/src/cli/plugins-update-command.ts @@ -1,5 +1,12 @@ import { loadConfig, readConfigFileSnapshot, replaceConfigFile } from "../config/config.js"; import { updateNpmInstalledHookPacks } from "../hooks/update.js"; +import { + loadPluginInstallRecords, + PLUGIN_INSTALLS_CONFIG_PATH, + withoutPluginInstallRecords, + writePersistedPluginInstallLedger, + withPluginInstallRecords, +} from "../plugins/install-ledger-store.js"; import { updateNpmInstalledPlugins } from "../plugins/update.js"; import { defaultRuntime } from "../runtime.js"; import { theme } from "../terminal/theme.js"; @@ -16,12 +23,14 @@ export async function runPluginUpdateCommand(params: { }) { const sourceSnapshotPromise = readConfigFileSnapshot().catch(() => null); const cfg = loadConfig(); + const pluginInstallRecords = await loadPluginInstallRecords({ config: cfg }); + const cfgWithPluginInstallRecords = withPluginInstallRecords(cfg, pluginInstallRecords); const logger = { info: (msg: string) => defaultRuntime.log(msg), warn: (msg: string) => defaultRuntime.log(theme.warn(msg)), }; const pluginSelection = resolvePluginUpdateSelection({ - installs: cfg.plugins?.installs ?? {}, + installs: pluginInstallRecords, rawId: params.id, all: params.opts.all, }); @@ -41,7 +50,7 @@ export async function runPluginUpdateCommand(params: { } const pluginResult = await updateNpmInstalledPlugins({ - config: cfg, + config: cfgWithPluginInstallRecords, pluginIds: pluginSelection.pluginIds, specOverrides: pluginSelection.specOverrides, dryRun: params.opts.dryRun, @@ -109,13 +118,25 @@ export async function runPluginUpdateCommand(params: { } if (!params.opts.dryRun && (pluginResult.changed || hookResult.changed)) { + const nextPluginInstallRecords = pluginResult.config.plugins?.installs ?? {}; + const shouldPersistPluginInstallLedger = + pluginResult.changed || Object.keys(pluginInstallRecords).length > 0; + if (shouldPersistPluginInstallLedger) { + await writePersistedPluginInstallLedger(nextPluginInstallRecords); + } + const nextConfig = shouldPersistPluginInstallLedger + ? withoutPluginInstallRecords(hookResult.config) + : hookResult.config; await replaceConfigFile({ - nextConfig: hookResult.config, + nextConfig, baseHash: (await sourceSnapshotPromise)?.hash, + ...(shouldPersistPluginInstallLedger + ? { writeOptions: { unsetPaths: [Array.from(PLUGIN_INSTALLS_CONFIG_PATH)] } } + : {}), }); if (pluginResult.changed) { await refreshPluginRegistryAfterConfigMutation({ - config: hookResult.config, + config: nextConfig, reason: "source-changed", logger, }); diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index 2349e72dc59..04345eae6fa 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -148,6 +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(); + return { + ...actual, + loadPluginInstallRecords: vi.fn(async ({ config }) => config?.plugins?.installs ?? {}), + writePersistedPluginInstallLedger: vi.fn(async () => undefined), + }; +}); + vi.mock("../daemon/service.js", () => ({ resolveGatewayService: vi.fn(() => ({ isLoaded: (...args: unknown[]) => serviceLoaded(...args), diff --git a/src/cli/update-cli/update-command.ts b/src/cli/update-cli/update-command.ts index 903ce6c349a..a6a076b6fbd 100644 --- a/src/cli/update-cli/update-command.ts +++ b/src/cli/update-cli/update-command.ts @@ -42,6 +42,13 @@ import { resolveGlobalInstallSpec, } from "../../infra/update-global.js"; import { runGatewayUpdate, type UpdateRunResult } from "../../infra/update-runner.js"; +import { + loadPluginInstallRecords, + PLUGIN_INSTALLS_CONFIG_PATH, + withoutPluginInstallRecords, + writePersistedPluginInstallLedger, + withPluginInstallRecords, +} from "../../plugins/install-ledger-store.js"; import { syncPluginsForUpdateChannel, updateNpmInstalledPlugins } from "../../plugins/update.js"; import { runCommandWithTimeout } from "../../process/exec.js"; import { defaultRuntime } from "../../runtime.js"; @@ -577,8 +584,11 @@ async function updatePluginsAfterCoreUpdate(params: { defaultRuntime.log(theme.heading("Updating plugins...")); } - const syncResult = await syncPluginsForUpdateChannel({ + const pluginInstallRecords = await loadPluginInstallRecords({ config: params.configSnapshot.sourceConfig, + }); + const syncResult = await syncPluginsForUpdateChannel({ + config: withPluginInstallRecords(params.configSnapshot.sourceConfig, pluginInstallRecords), channel: params.channel, workspaceDir: params.root, externalizedBundledPluginBridges: await listPersistedBundledPluginLocationBridges({ @@ -620,12 +630,15 @@ async function updatePluginsAfterCoreUpdate(params: { pluginConfig = npmResult.config; if (syncResult.changed || npmResult.changed) { + await writePersistedPluginInstallLedger(pluginConfig.plugins?.installs ?? {}); + const nextConfig = withoutPluginInstallRecords(pluginConfig); await replaceConfigFile({ - nextConfig: pluginConfig, + nextConfig, baseHash: params.configSnapshot.hash, + writeOptions: { unsetPaths: [Array.from(PLUGIN_INSTALLS_CONFIG_PATH)] }, }); await refreshPluginRegistryAfterConfigMutation({ - config: pluginConfig, + config: nextConfig, reason: "source-changed", workspaceDir: params.root, logger: pluginLogger, diff --git a/src/commands/channels/add.ts b/src/commands/channels/add.ts index f001f3c3a73..36dccfb1e65 100644 --- a/src/commands/channels/add.ts +++ b/src/commands/channels/add.ts @@ -6,6 +6,11 @@ import type { ChannelSetupPlugin } from "../../channels/plugins/setup-wizard-typ import type { ChannelPlugin } from "../../channels/plugins/types.plugin.js"; import type { ChannelId, ChannelSetupInput } from "../../channels/plugins/types.public.js"; import { replaceConfigFile, type OpenClawConfig } from "../../config/config.js"; +import { + PLUGIN_INSTALLS_CONFIG_PATH, + withoutPluginInstallRecords, + writePersistedPluginInstallLedger, +} from "../../plugins/install-ledger-store.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"; @@ -237,13 +242,25 @@ export async function channelsAddCommand( } } + const shouldMovePluginInstalls = Boolean( + nextConfig.plugins?.installs && Object.keys(nextConfig.plugins.installs).length > 0, + ); + const writtenConfig = shouldMovePluginInstalls + ? withoutPluginInstallRecords(nextConfig) + : nextConfig; + if (shouldMovePluginInstalls) { + await writePersistedPluginInstallLedger(nextConfig.plugins?.installs ?? {}); + } await replaceConfigFile({ - nextConfig, + nextConfig: writtenConfig, ...(baseHash !== undefined ? { baseHash } : {}), + ...(shouldMovePluginInstalls + ? { writeOptions: { unsetPaths: [Array.from(PLUGIN_INSTALLS_CONFIG_PATH)] } } + : {}), }); await onboardChannels.runCollectedChannelOnboardingPostWriteHooks({ hooks: postWriteHooks.drain(), - cfg: nextConfig, + cfg: writtenConfig, runtime, }); await prompter.outro("Channels updated."); @@ -368,9 +385,21 @@ export async function channelsAddCommand( runtime, }); + const shouldMovePluginInstalls = Boolean( + nextConfig.plugins?.installs && Object.keys(nextConfig.plugins.installs).length > 0, + ); + const writtenConfig = shouldMovePluginInstalls + ? withoutPluginInstallRecords(nextConfig) + : nextConfig; + if (shouldMovePluginInstalls) { + await writePersistedPluginInstallLedger(nextConfig.plugins?.installs ?? {}); + } await replaceConfigFile({ - nextConfig, + nextConfig: writtenConfig, ...(baseHash !== undefined ? { baseHash } : {}), + ...(shouldMovePluginInstalls + ? { writeOptions: { unsetPaths: [Array.from(PLUGIN_INSTALLS_CONFIG_PATH)] } } + : {}), }); runtime.log(`Added ${plugin.meta.label ?? channelLabel(channel)} account "${accountId}".`); const afterAccountConfigWritten = plugin.setup?.afterAccountConfigWritten; diff --git a/src/commands/channels/capabilities.ts b/src/commands/channels/capabilities.ts index 05d547374ff..9fe6f29e250 100644 --- a/src/commands/channels/capabilities.ts +++ b/src/commands/channels/capabilities.ts @@ -17,6 +17,11 @@ import { } from "../../config/config.js"; import { danger } from "../../globals.js"; import { formatErrorMessage } from "../../infra/errors.js"; +import { + PLUGIN_INSTALLS_CONFIG_PATH, + withoutPluginInstallRecords, + writePersistedPluginInstallLedger, +} from "../../plugins/install-ledger-store.js"; import { defaultRuntime, type RuntimeEnv, writeRuntimeJson } from "../../runtime.js"; import { normalizeLowercaseStringOrEmpty, @@ -250,9 +255,19 @@ export async function channelsCapabilitiesCommand( }); if (resolved.configChanged) { cfg = resolved.cfg; + const shouldMovePluginInstalls = Boolean( + cfg.plugins?.installs && Object.keys(cfg.plugins.installs).length > 0, + ); + if (shouldMovePluginInstalls) { + await writePersistedPluginInstallLedger(cfg.plugins?.installs ?? {}); + cfg = withoutPluginInstallRecords(cfg); + } await replaceConfigFile({ nextConfig: cfg, baseHash: (await sourceSnapshotPromise)?.hash, + ...(shouldMovePluginInstalls + ? { writeOptions: { unsetPaths: [Array.from(PLUGIN_INSTALLS_CONFIG_PATH)] } } + : {}), }); } return resolved.plugin ? [resolved.plugin] : null; diff --git a/src/commands/channels/remove.ts b/src/commands/channels/remove.ts index ba76a78c969..b8813d34531 100644 --- a/src/commands/channels/remove.ts +++ b/src/commands/channels/remove.ts @@ -5,6 +5,11 @@ import { normalizeChannelId, } from "../../channels/plugins/index.js"; import { replaceConfigFile, type OpenClawConfig } from "../../config/config.js"; +import { + PLUGIN_INSTALLS_CONFIG_PATH, + withoutPluginInstallRecords, + writePersistedPluginInstallLedger, +} from "../../plugins/install-ledger-store.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"; @@ -171,9 +176,19 @@ export async function channelsRemoveCommand( }); } + const shouldMovePluginInstalls = Boolean( + next.plugins?.installs && Object.keys(next.plugins.installs).length > 0, + ); + if (shouldMovePluginInstalls) { + await writePersistedPluginInstallLedger(next.plugins?.installs ?? {}); + next = withoutPluginInstallRecords(next); + } await replaceConfigFile({ nextConfig: next, ...(baseHash !== undefined ? { baseHash } : {}), + ...(shouldMovePluginInstalls + ? { writeOptions: { unsetPaths: [Array.from(PLUGIN_INSTALLS_CONFIG_PATH)] } } + : {}), }); if (useWizard && prompter) { await prompter.outro( diff --git a/src/commands/channels/resolve.ts b/src/commands/channels/resolve.ts index 2cc015fcfc6..9163b4568f0 100644 --- a/src/commands/channels/resolve.ts +++ b/src/commands/channels/resolve.ts @@ -8,6 +8,11 @@ import { getChannelsCommandSecretTargetIds } from "../../cli/command-secret-targ import { loadConfig, readConfigFileSnapshot, replaceConfigFile } from "../../config/config.js"; import { danger } from "../../globals.js"; import { resolveMessageChannelSelection } from "../../infra/outbound/channel-selection.js"; +import { + PLUGIN_INSTALLS_CONFIG_PATH, + withoutPluginInstallRecords, + writePersistedPluginInstallLedger, +} from "../../plugins/install-ledger-store.js"; import { type RuntimeEnv, writeRuntimeJson } from "../../runtime.js"; import { normalizeLowercaseStringOrEmpty, @@ -139,9 +144,19 @@ export async function channelsResolveCommand(opts: ChannelsResolveOptions, runti : null; if (resolvedExplicit?.configChanged) { cfg = resolvedExplicit.cfg; + const shouldMovePluginInstalls = Boolean( + cfg.plugins?.installs && Object.keys(cfg.plugins.installs).length > 0, + ); + if (shouldMovePluginInstalls) { + await writePersistedPluginInstallLedger(cfg.plugins?.installs ?? {}); + cfg = withoutPluginInstallRecords(cfg); + } await replaceConfigFile({ nextConfig: cfg, baseHash: (await sourceSnapshotPromise)?.hash, + ...(shouldMovePluginInstalls + ? { writeOptions: { unsetPaths: [Array.from(PLUGIN_INSTALLS_CONFIG_PATH)] } } + : {}), }); } diff --git a/src/commands/doctor/shared/plugin-registry-migration.ts b/src/commands/doctor/shared/plugin-registry-migration.ts index bdefe872a0f..1bae6d94385 100644 --- a/src/commands/doctor/shared/plugin-registry-migration.ts +++ b/src/commands/doctor/shared/plugin-registry-migration.ts @@ -1,5 +1,9 @@ import fs from "node:fs"; import type { OpenClawConfig } from "../../../config/types.openclaw.js"; +import { + loadPluginInstallRecords, + writePersistedPluginInstallLedger, +} from "../../../plugins/install-ledger-store.js"; import { inspectPersistedInstalledPluginIndex, readPersistedInstalledPluginIndexSync, @@ -122,6 +126,7 @@ export async function migratePluginRegistryForInstall( } const config = await readMigrationConfig(params); + const installRecords = await loadPluginInstallRecords({ ...params, config }); const migrationParams = { ...params, config, @@ -136,6 +141,9 @@ export async function migratePluginRegistryForInstall( refreshReason: "migration", plugins: listEnabledInstalledPluginRecords(candidateIndex, config), }; + if (Object.keys(installRecords).length > 0) { + await writePersistedPluginInstallLedger(installRecords, params); + } await writePersistedInstalledPluginIndex(current, params); return { status: "migrated", diff --git a/src/commands/onboarding-plugin-install.ts b/src/commands/onboarding-plugin-install.ts index 4af74577b6d..0ab6d971b3c 100644 --- a/src/commands/onboarding-plugin-install.ts +++ b/src/commands/onboarding-plugin-install.ts @@ -8,6 +8,11 @@ import { resolveBundledPluginSources, } from "../plugins/bundled-sources.js"; import { enablePluginInConfig, type PluginEnableResult } from "../plugins/enable.js"; +import { + loadPluginInstallRecords, + recordPluginInstallInRecords, + writePersistedPluginInstallLedger, +} from "../plugins/install-ledger-store.js"; import { installPluginFromNpmSpec } from "../plugins/install.js"; import { buildNpmResolutionInstallFields, recordPluginInstall } from "../plugins/installs.js"; import type { PluginPackageInstall } from "../plugins/manifest.js"; @@ -135,20 +140,33 @@ function formatPortableLocalPath(localPath: string, workspaceDir?: string): stri return undefined; } -function recordLocalPluginInstall(params: { +async function persistOnboardingPluginInstallRecord(params: { + cfg: OpenClawConfig; + install: Parameters[1]; +}) { + const records = await loadPluginInstallRecords({ config: params.cfg }); + await writePersistedPluginInstallLedger(recordPluginInstallInRecords(records, params.install)); +} + +async function recordLocalPluginInstall(params: { cfg: OpenClawConfig; entry: OnboardingPluginInstallEntry; localPath: string; npmSpec?: string | null; workspaceDir?: string; -}): OpenClawConfig { +}): Promise { const sourcePath = formatPortableLocalPath(params.localPath, params.workspaceDir); - return recordPluginInstall(params.cfg, { + const install = { pluginId: params.entry.pluginId, source: "path", ...(sourcePath ? { sourcePath } : {}), ...(params.npmSpec ? { spec: params.npmSpec } : {}), + } as const; + await persistOnboardingPluginInstallRecord({ + cfg: params.cfg, + install, }); + return recordPluginInstall(params.cfg, install); } function resolveLocalPath(params: { @@ -474,7 +492,7 @@ export async function ensureOnboardingPluginInstalled(params: { }; } next = addPluginLoadPath(enableResult.config, localPath); - next = recordLocalPluginInstall({ cfg: next, entry, localPath, npmSpec, workspaceDir }); + next = await recordLocalPluginInstall({ cfg: next, entry, localPath, npmSpec, workspaceDir }); return { cfg: next, installed: true, @@ -544,14 +562,19 @@ export async function ensureOnboardingPluginInstalled(params: { }; } next = enableResult.config; - next = recordPluginInstall(next, { + const install = { pluginId: result.pluginId, source: "npm", spec: npmSpec, installPath: result.targetDir, version: result.version, ...buildNpmResolutionInstallFields(result.npmResolution), + } as const; + await persistOnboardingPluginInstallRecord({ + cfg: next, + install, }); + next = recordPluginInstall(next, install); return { cfg: next, installed: true, @@ -590,7 +613,7 @@ export async function ensureOnboardingPluginInstalled(params: { }; } next = addPluginLoadPath(enableResult.config, localPath); - next = recordLocalPluginInstall({ cfg: next, entry, localPath, npmSpec, workspaceDir }); + next = await recordLocalPluginInstall({ cfg: next, entry, localPath, npmSpec, workspaceDir }); return { cfg: next, installed: true, diff --git a/src/config/schema.base.generated.ts b/src/config/schema.base.generated.ts index f3cc7e0e516..0e7cff7bbf2 100644 --- a/src/config/schema.base.generated.ts +++ b/src/config/schema.base.generated.ts @@ -23125,7 +23125,7 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = { }, title: "Plugin Install Records", description: - "CLI-managed install metadata (used by `openclaw plugins update` to locate install sources).", + "Deprecated compatibility fallback for legacy CLI-managed install metadata. New plugin installs use the state-managed `plugins/installs.json` ledger.", }, }, additionalProperties: false, @@ -27610,7 +27610,7 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = { }, "plugins.installs": { label: "Plugin Install Records", - help: "CLI-managed install metadata (used by `openclaw plugins update` to locate install sources).", + help: "Deprecated compatibility fallback for legacy CLI-managed install metadata. New plugin installs use the state-managed `plugins/installs.json` ledger.", tags: ["advanced"], }, "plugins.installs.*.source": { diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 48df41d1875..61f40dd138d 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -1161,7 +1161,7 @@ export const FIELD_HELP: Record = { "plugins.entries.*.config": "Plugin-defined configuration payload interpreted by that plugin's own schema and validation rules. Use only documented fields from the plugin to prevent ignored or invalid settings.", "plugins.installs": - "CLI-managed install metadata (used by `openclaw plugins update` to locate install sources).", + "Deprecated compatibility fallback for legacy CLI-managed install metadata. New plugin installs use the state-managed `plugins/installs.json` ledger.", "plugins.installs.*.source": 'Install source ("npm", "archive", or "path").', "plugins.installs.*.spec": "Original npm spec used for install (if source is npm).", "plugins.installs.*.sourcePath": "Original archive/path used for install (if any).", diff --git a/src/plugins/compat/registry.ts b/src/plugins/compat/registry.ts index 282da5406fc..a6ccf77dc0f 100644 --- a/src/plugins/compat/registry.ts +++ b/src/plugins/compat/registry.ts @@ -223,6 +223,26 @@ export const PLUGIN_COMPAT_RECORDS = [ diagnostics: ["persisted-registry-disabled"], tests: ["src/plugins/plugin-registry.test.ts"], }, + { + code: "legacy-config-plugin-installs", + status: "deprecated", + owner: "config", + introduced: "2026-04-25", + deprecated: "2026-04-25", + warningStarts: "2026-04-25", + replacement: "state-managed `plugins/installs.json` plugin install ledger", + docsPath: "/cli/plugins#install-ledger", + surfaces: ["plugins.installs", "plugin install/update/uninstall", "plugin registry migration"], + diagnostics: ["plugin install ledger compatibility"], + tests: [ + "src/plugins/install-ledger-store.test.ts", + "src/cli/plugins-install-persist.test.ts", + "src/cli/plugins-cli.update.test.ts", + "src/cli/plugins-cli.uninstall.test.ts", + ], + releaseNote: + "`plugins.installs` remains readable as a legacy compatibility fallback while new plugin install metadata moves to the state-managed install ledger.", + }, ] as const satisfies readonly PluginCompatRecord[]; export type PluginCompatCode = (typeof PLUGIN_COMPAT_RECORDS)[number]["code"]; diff --git a/src/plugins/install-ledger-store.test.ts b/src/plugins/install-ledger-store.test.ts new file mode 100644 index 00000000000..d0bd1928218 --- /dev/null +++ b/src/plugins/install-ledger-store.test.ts @@ -0,0 +1,197 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + loadPluginInstallRecords, + loadPluginInstallRecordsSync, + PLUGIN_INSTALL_LEDGER_WARNING, + readPersistedPluginInstallLedger, + recordPluginInstallInRecords, + removePluginInstallRecordFromRecords, + resolvePluginInstallLedgerStorePath, + withoutPluginInstallRecords, + writePersistedPluginInstallLedger, +} from "./install-ledger-store.js"; + +const tempDirs: string[] = []; + +function makeStateDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-plugin-ledger-")); + tempDirs.push(dir); + return dir; +} + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("plugin install ledger store", () => { + it("writes machine-managed install records outside config", async () => { + const stateDir = makeStateDir(); + + await writePersistedPluginInstallLedger( + { + twitch: { + source: "npm", + spec: "@openclaw/plugin-twitch@1.0.0", + installPath: "plugins/npm/@openclaw/plugin-twitch", + }, + }, + { + stateDir, + 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({ + 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", + }, + }, + }); + }); + + it("prefers persisted records over legacy config installs", async () => { + const stateDir = makeStateDir(); + await writePersistedPluginInstallLedger( + { + persisted: { + source: "npm", + spec: "persisted@1.0.0", + }, + }, + { stateDir }, + ); + + await expect( + loadPluginInstallRecords({ + stateDir, + config: { + plugins: { + installs: { + legacy: { + source: "npm", + spec: "legacy@1.0.0", + }, + }, + }, + }, + }), + ).resolves.toEqual({ + persisted: { + source: "npm", + spec: "persisted@1.0.0", + }, + }); + }); + + it("falls back to legacy config installs when no ledger exists", () => { + const stateDir = makeStateDir(); + + expect( + loadPluginInstallRecordsSync({ + stateDir, + config: { + plugins: { + installs: { + legacy: { + source: "path", + sourcePath: "./plugins/legacy", + }, + }, + }, + }, + }), + ).toEqual({ + legacy: { + source: "path", + sourcePath: "./plugins/legacy", + }, + }); + }); + + it("updates and removes records without mutating caller state", async () => { + const records = { + keep: { + source: "npm", + spec: "keep@1.0.0", + }, + }; + const withInstall = recordPluginInstallInRecords(records, { + pluginId: "demo", + source: "npm", + spec: "demo@latest", + installedAt: "2026-04-25T00:00:00.000Z", + }); + + expect(records).toEqual({ + keep: { + source: "npm", + spec: "keep@1.0.0", + }, + }); + expect(withInstall.demo).toMatchObject({ + source: "npm", + spec: "demo@latest", + installedAt: "2026-04-25T00:00:00.000Z", + }); + expect(removePluginInstallRecordFromRecords(withInstall, "demo")).toEqual(records); + }); + + it("strips legacy installs from config writes", () => { + expect( + withoutPluginInstallRecords({ + plugins: { + entries: { + twitch: { enabled: true }, + }, + installs: { + twitch: { source: "npm", spec: "twitch@1.0.0" }, + }, + }, + }), + ).toEqual({ + plugins: { + entries: { + twitch: { enabled: true }, + }, + }, + }); + }); + + it("ignores invalid persisted ledgers and falls back to config", async () => { + const stateDir = makeStateDir(); + fs.mkdirSync(path.join(stateDir, "plugins"), { recursive: true }); + fs.writeFileSync( + resolvePluginInstallLedgerStorePath({ stateDir }), + JSON.stringify({ version: 999, records: {} }), + ); + + await expect(readPersistedPluginInstallLedger({ stateDir })).resolves.toBeNull(); + await expect( + loadPluginInstallRecords({ + stateDir, + config: { + plugins: { + installs: { + legacy: { source: "npm", spec: "legacy@1.0.0" }, + }, + }, + }, + }), + ).resolves.toEqual({ + legacy: { source: "npm", spec: "legacy@1.0.0" }, + }); + }); +}); diff --git a/src/plugins/install-ledger-store.ts b/src/plugins/install-ledger-store.ts new file mode 100644 index 00000000000..e31a349186a --- /dev/null +++ b/src/plugins/install-ledger-store.ts @@ -0,0 +1,162 @@ +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/installed-plugin-index.ts b/src/plugins/installed-plugin-index.ts index e0e9ba5f85f..5a9a9224adb 100644 --- a/src/plugins/installed-plugin-index.ts +++ b/src/plugins/installed-plugin-index.ts @@ -7,6 +7,7 @@ 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, @@ -82,9 +83,8 @@ export type InstalledPluginIndexRecord = { packageName?: string; packageVersion?: string; /** - * Actual install ledger entry recorded by OpenClaw under - * cfg.plugins.installs[pluginId]. This is the durable source of truth for - * what onboarding/update installed. + * Actual install ledger entry recorded by OpenClaw in the plugin install + * ledger. Legacy cfg.plugins.installs is only a compatibility fallback. */ installRecord?: InstalledPluginInstallRecordInfo; /** Hash of installRecord; used to detect source-changed invalidation. */ @@ -470,10 +470,14 @@ 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, + }); const plugins = registry.plugins.map((record): InstalledPluginIndexRecord => { const candidate = candidateByRootDir.get(record.rootDir); const packageJsonPath = resolvePackageJsonPath(candidate); - const installRecord = normalizeInstallRecord(params.config?.plugins?.installs?.[record.id]); + const installRecord = normalizeInstallRecord(installRecords[record.id]); const packageInstall = describePackageInstallSource(candidate); const manifestHash = safeHashFile({ diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 08dac4b6b48..5ce44c1c2db 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -61,6 +61,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 { clearPluginInteractiveHandlers, listPluginInteractiveHandlers, @@ -1039,6 +1040,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 cacheKey = buildCacheKey({ workspaceDir: options.workspaceDir, plugins: trustNormalized, @@ -1046,7 +1048,7 @@ function resolvePluginLoadCacheContext(options: PluginLoadOptions = {}) { activationSource, autoEnabledReasons: options.autoEnabledReasons ?? {}, }), - installs: cfg.plugins?.installs, + installs: installRecords, env, onlyPluginIds, includeSetupOnlyChannelPlugins, @@ -1751,7 +1753,10 @@ function buildProvenanceIndex(params: { } const installRules = new Map(); - const installs = params.config.plugins?.installs ?? {}; + const installs = loadPluginInstallRecordsSync({ + config: params.config, + 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 21d1640ae09..1fcb4ee6ffd 100644 --- a/src/plugins/manifest-registry.ts +++ b/src/plugins/manifest-registry.ts @@ -16,6 +16,7 @@ import { type NormalizedPluginsConfig, } from "./config-policy.js"; import { discoverOpenClawPlugins, type PluginCandidate } from "./discovery.js"; +import { loadPluginInstallRecordsSync } from "./install-ledger-store.js"; import type { PluginManifestCommandAlias } from "./manifest-command-aliases.js"; import { clearPluginManifestRegistryCache, @@ -537,7 +538,10 @@ function matchesInstalledPluginRecord(params: { if (params.candidate.origin !== "global") { return false; } - const record = params.config?.plugins?.installs?.[params.pluginId]; + const record = loadPluginInstallRecordsSync({ + config: params.config, + env: params.env, + })[params.pluginId]; if (!record) { return false; } diff --git a/src/security/audit-plugins-trust.ts b/src/security/audit-plugins-trust.ts index 6301d995ada..0d5c55a8271 100644 --- a/src/security/audit-plugins-trust.ts +++ b/src/security/audit-plugins-trust.ts @@ -7,6 +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 { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; import type { SecurityAuditFinding } from "./audit.types.js"; @@ -420,7 +421,7 @@ export async function collectPluginsTrustFindings(params: { } } - const pluginInstalls = params.cfg.plugins?.installs ?? {}; + const pluginInstalls = await loadPluginInstallRecords({ config: params.cfg }); const npmPluginInstalls = Object.entries(pluginInstalls).filter( ([, record]) => record?.source === "npm", );