feat(plugins): move install records to managed ledger

This commit is contained in:
Vincent Koc
2026-04-25 11:36:42 -07:00
parent e473577eaa
commit 888448facc
31 changed files with 721 additions and 74 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -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 <id> --dry-run
openclaw plugins uninstall <id> --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

View File

@@ -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).

View File

@@ -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

View File

@@ -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(),
}));

View File

@@ -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<string, PluginInstallRecord>;
report: PluginStatusReport;
}): {
inspect: NonNullable<ReturnType<typeof buildPluginInspectReport>>;
@@ -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<string, PluginInstallRecord>;
report: PluginStatusReport;
}): Array<{
inspect: ReturnType<typeof buildAllPluginInspectReports>[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) {

View File

@@ -32,6 +32,11 @@ export const listMarketplacePlugins: Mock<ListMarketplacePluginsFn> = vi.fn();
export const resolveMarketplaceInstallShortcut: Mock<ResolveMarketplaceInstallShortcutFn> = vi.fn();
export const enablePluginInConfig: UnknownMock = vi.fn();
export const recordPluginInstall: UnknownMock = vi.fn();
export const loadPluginInstallRecords: AsyncUnknownMock = vi.fn(async ({ 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<typeof import("../plugins/install-ledger-store.js")>();
return {
...actual,
loadPluginInstallRecords: ((...args: unknown[]) =>
invokeMock<unknown[], unknown>(loadPluginInstallRecords, ...args)) as (
...args: unknown[]
) => unknown,
writePersistedPluginInstallLedger: ((...args: unknown[]) =>
invokeMock<unknown[], unknown>(writePersistedPluginInstallLedger, ...args)) as (
...args: unknown[]
) => unknown,
recordPluginInstallInRecords: (
records: Record<string, unknown>,
update: { pluginId: string; installedAt?: string } & Record<string, unknown>,
) => {
const { pluginId, ...record } = update;
return {
...records,
[pluginId]: {
...(records[pluginId] as Record<string, unknown> | 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: [],

View File

@@ -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)),

View File

@@ -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",
});
});

View File

@@ -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(

View File

@@ -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",
});
});

View File

@@ -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,

View File

@@ -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,
});

View File

@@ -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<typeof import("../plugins/install-ledger-store.js")>();
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),

View File

@@ -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,

View File

@@ -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;

View File

@@ -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;

View File

@@ -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(

View File

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

View File

@@ -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",

View File

@@ -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<typeof recordPluginInstallInRecords>[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<OpenClawConfig> {
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,

View File

@@ -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": {

View File

@@ -1161,7 +1161,7 @@ export const FIELD_HELP: Record<string, string> = {
"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).",

View File

@@ -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"];

View File

@@ -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" },
});
});
});

View File

@@ -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<string, PluginInstallRecord>;
};
export type PluginInstallLedgerStoreOptions = {
env?: NodeJS.ProcessEnv;
stateDir?: string;
filePath?: string;
};
const PluginInstallRecordSchema = z
.object({
source: z.string(),
})
.passthrough();
const PluginInstallLedgerSchema = z
.object({
version: z.literal(PLUGIN_INSTALL_LEDGER_VERSION),
warning: z.string().optional(),
updatedAtMs: z.number(),
records: z.record(z.string(), PluginInstallRecordSchema),
})
.passthrough();
function parsePluginInstallLedger(value: unknown): PluginInstallLedger | null {
return safeParseWithSchema(PluginInstallLedgerSchema, value) as PluginInstallLedger | null;
}
function cloneInstallRecords(
records: Record<string, PluginInstallRecord> | undefined,
): Record<string, PluginInstallRecord> {
return structuredClone(records ?? {});
}
export function resolvePluginInstallLedgerStorePath(
options: PluginInstallLedgerStoreOptions = {},
): string {
if (options.filePath) {
return options.filePath;
}
const env = options.env ?? process.env;
const stateDir = options.stateDir ?? resolveStateDir(env);
return path.join(stateDir, PLUGIN_INSTALL_LEDGER_STORE_PATH);
}
export async function readPersistedPluginInstallLedger(
options: PluginInstallLedgerStoreOptions = {},
): Promise<PluginInstallLedger | null> {
const parsed = await readJsonFile<unknown>(resolvePluginInstallLedgerStorePath(options));
return parsePluginInstallLedger(parsed);
}
export function readPersistedPluginInstallLedgerSync(
options: PluginInstallLedgerStoreOptions = {},
): PluginInstallLedger | null {
const parsed = readJsonFileSync(resolvePluginInstallLedgerStorePath(options));
return parsePluginInstallLedger(parsed);
}
export async function writePersistedPluginInstallLedger(
records: Record<string, PluginInstallRecord>,
options: PluginInstallLedgerStoreOptions & { now?: () => Date } = {},
): Promise<string> {
const filePath = resolvePluginInstallLedgerStorePath(options);
await writeJsonAtomic(
filePath,
{
version: PLUGIN_INSTALL_LEDGER_VERSION,
warning: PLUGIN_INSTALL_LEDGER_WARNING,
updatedAtMs: (options.now ?? (() => new Date()))().getTime(),
records,
} satisfies PluginInstallLedger,
{
trailingNewline: true,
ensureDirMode: 0o700,
mode: 0o600,
},
);
return filePath;
}
export async function loadPluginInstallRecords(
params: PluginInstallLedgerStoreOptions & { config?: OpenClawConfig } = {},
): Promise<Record<string, PluginInstallRecord>> {
const ledger = await readPersistedPluginInstallLedger(params);
if (ledger) {
return cloneInstallRecords(ledger.records);
}
return cloneInstallRecords(params.config?.plugins?.installs);
}
export function loadPluginInstallRecordsSync(
params: PluginInstallLedgerStoreOptions & { config?: OpenClawConfig } = {},
): Record<string, PluginInstallRecord> {
const ledger = readPersistedPluginInstallLedgerSync(params);
if (ledger) {
return cloneInstallRecords(ledger.records);
}
return cloneInstallRecords(params.config?.plugins?.installs);
}
export function withPluginInstallRecords(
config: OpenClawConfig,
records: Record<string, PluginInstallRecord>,
): OpenClawConfig {
return {
...config,
plugins: {
...config.plugins,
installs: records,
},
};
}
export function withoutPluginInstallRecords(config: OpenClawConfig): OpenClawConfig {
if (!config.plugins?.installs) {
return config;
}
const { installs: _installs, ...plugins } = config.plugins;
if (Object.keys(plugins).length === 0) {
const { plugins: _plugins, ...rest } = config;
return rest;
}
return {
...config,
plugins,
};
}
export function recordPluginInstallInRecords(
records: Record<string, PluginInstallRecord>,
update: PluginInstallUpdate,
): Record<string, PluginInstallRecord> {
return recordPluginInstall({ plugins: { installs: records } }, update).plugins?.installs ?? {};
}
export function removePluginInstallRecordFromRecords(
records: Record<string, PluginInstallRecord>,
pluginId: string,
): Record<string, PluginInstallRecord> {
const { [pluginId]: _removed, ...rest } = records;
return rest;
}

View File

@@ -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({

View File

@@ -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<string, InstallTrackingRule>();
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,

View File

@@ -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;
}

View File

@@ -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",
);