Skip to content

Commit 888448f

Browse files
committed
feat(plugins): move install records to managed ledger
1 parent e473577 commit 888448f

31 files changed

Lines changed: 721 additions & 74 deletions

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ Docs: https://docs.openclaw.ai
2929
- 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.
3030
- 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.
3131
- 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.
32+
- Plugins/install: move managed plugin install metadata from `plugins.installs`
33+
to the state-managed `plugins/installs.json` ledger, with legacy config reads
34+
kept as a deprecated compatibility fallback. Thanks @vincentkoc.
3235
- 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.
3336
- 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.
3437
- 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.
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
439ff58a4a54f0f4bda959239f382cc3b2f94a282680dcd89bd3f8c93e0f07d0 config-baseline.json
2-
6ef86147534d12aa5ac7a9cf208b4627177090c92479a71dfd1791096d20353b config-baseline.core.json
1+
c8d24c55df89a76f44cd6ab5fdb7c28b0b3a8adadcd2c94a1d81263512075c0f config-baseline.json
2+
97c37380e03c167ee710adb0ee297573146e78434635780226b744841628370b config-baseline.core.json
33
7cd9c908f066c143eab2a201efbc9640f483ab28bba92ddeca1d18cc2b528bc3 config-baseline.channel.json
44
7825b56a5b3fcdbe2e09ef8fe5d9f12ac3598435afebe20413051e45b0d1968e config-baseline.plugin.json

docs/cli/plugins.md

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -231,7 +231,19 @@ openclaw plugins install -l ./my-plugin
231231
source path instead of copying over a managed install target.
232232

233233
Use `--pin` on npm installs to save the resolved exact spec (`name@version`) in
234-
`plugins.installs` while keeping the default behavior unpinned.
234+
the managed install ledger while keeping the default behavior unpinned.
235+
236+
### Install Ledger
237+
238+
Plugin install metadata is machine-managed state, not user config. New installs
239+
and updates write it to `plugins/installs.json` under the active OpenClaw state
240+
directory. The file includes a do-not-edit warning and is used by
241+
`openclaw plugins update`, uninstall, diagnostics, and the cold plugin registry.
242+
243+
Legacy `plugins.installs` entries in `openclaw.json` remain readable as a
244+
deprecated compatibility fallback. When install/update/uninstall paths rewrite
245+
plugin install state, OpenClaw writes the ledger file and removes
246+
`plugins.installs` from the persisted config payload.
235247

236248
### Uninstall
237249

@@ -241,8 +253,9 @@ openclaw plugins uninstall <id> --dry-run
241253
openclaw plugins uninstall <id> --keep-files
242254
```
243255

244-
`uninstall` removes plugin records from `plugins.entries`, `plugins.installs`,
245-
the plugin allowlist, and linked `plugins.load.paths` entries when applicable.
256+
`uninstall` removes plugin records from `plugins.entries`, the managed install
257+
ledger, the plugin allowlist, and linked `plugins.load.paths` entries when
258+
applicable.
246259
For active memory plugins, the memory slot resets to `memory-core`.
247260

248261
By default, uninstall also removes the plugin install directory under the active
@@ -261,8 +274,8 @@ openclaw plugins update @openclaw/voice-call@beta
261274
openclaw plugins update openclaw-codex-app-server --dangerously-force-unsafe-install
262275
```
263276

264-
Updates apply to tracked installs in `plugins.installs` and tracked hook-pack
265-
installs in `hooks.internal.installs`.
277+
Updates apply to tracked plugin installs in the managed install ledger and
278+
tracked hook-pack installs in `hooks.internal.installs`.
266279

267280
When you pass a plugin id, OpenClaw reuses the recorded install spec for that
268281
plugin. That means previously stored dist-tags such as `@beta` and exact pinned

docs/gateway/configuration-reference.md

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -186,9 +186,14 @@ See [MCP](/cli/mcp#openclaw-as-an-mcp-client-registry) and
186186
- 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.
187187
- `plugins.slots.memory`: pick the active memory plugin id, or `"none"` to disable memory plugins.
188188
- `plugins.slots.contextEngine`: pick the active context engine plugin id; defaults to `"legacy"` unless you install and select another engine.
189-
- `plugins.installs`: CLI-managed install metadata used by `openclaw plugins update`.
190-
- Includes `source`, `spec`, `sourcePath`, `installPath`, `version`, `resolvedName`, `resolvedVersion`, `resolvedSpec`, `integrity`, `shasum`, `resolvedAt`, `installedAt`.
191-
- Treat `plugins.installs.*` as managed state; prefer CLI commands over manual edits.
189+
- `plugins.installs`: deprecated compatibility fallback for legacy
190+
CLI-managed install metadata. New plugin installs write the managed
191+
`plugins/installs.json` state ledger instead.
192+
- Legacy records include `source`, `spec`, `sourcePath`, `installPath`,
193+
`version`, `resolvedName`, `resolvedVersion`, `resolvedSpec`, `integrity`,
194+
`shasum`, `resolvedAt`, `installedAt`.
195+
- Treat `plugins.installs.*` as managed state; prefer CLI commands over
196+
manual edits.
192197

193198
See [Plugins](/tools/plugin).
194199

docs/plugins/architecture-internals.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -911,13 +911,15 @@ Official external npm entries should prefer an exact `npmSpec` plus
911911
`expectedIntegrity`. Bare package names and dist-tags still work for
912912
compatibility, but they surface source-plane warnings so the catalog can move
913913
toward pinned, integrity-checked installs without breaking existing plugins.
914-
When onboarding installs from a local catalog path, it records a
915-
`plugins.installs` entry with `source: "path"` and a workspace-relative
914+
When onboarding installs from a local catalog path, it records a managed plugin
915+
install ledger entry with `source: "path"` and a workspace-relative
916916
`sourcePath` when possible. The absolute operational load path stays in
917917
`plugins.load.paths`; the install record avoids duplicating local workstation
918918
paths into long-lived config. This keeps local development installs visible to
919919
source-plane diagnostics without adding a second raw filesystem-path disclosure
920-
surface.
920+
surface. Legacy `plugins.installs` config entries are still read as a
921+
compatibility fallback while the state-managed `plugins/installs.json` ledger
922+
becomes the install source of truth.
921923

922924
## Context engine plugins
923925

src/auto-reply/reply/commands-plugins.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,10 @@ vi.mock("../../plugins/install.js", () => ({
5555
installPluginFromPath: vi.fn(),
5656
}));
5757

58+
vi.mock("../../plugins/install-ledger-store.js", () => ({
59+
loadPluginInstallRecords: vi.fn(async ({ config }) => config?.plugins?.installs ?? {}),
60+
}));
61+
5862
vi.mock("../../plugins/manifest-registry.js", () => ({
5963
clearPluginManifestRegistryCache: vi.fn(),
6064
}));

src/auto-reply/reply/commands-plugins.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import type { PluginInstallRecord } from "../../config/types.plugins.js";
1818
import { resolveArchiveKind } from "../../infra/archive.js";
1919
import { parseClawHubPluginSpec } from "../../infra/clawhub.js";
2020
import { installPluginFromClawHub } from "../../plugins/clawhub.js";
21+
import { loadPluginInstallRecords } from "../../plugins/install-ledger-store.js";
2122
import { installPluginFromNpmSpec, installPluginFromPath } from "../../plugins/install.js";
2223
import { clearPluginManifestRegistryCache } from "../../plugins/manifest-registry.js";
2324
import type { PluginRecord } from "../../plugins/registry.js";
@@ -49,6 +50,7 @@ function renderJsonBlock(label: string, value: unknown): string {
4950
function buildPluginInspectJson(params: {
5051
id: string;
5152
config: OpenClawConfig;
53+
installRecords: Record<string, PluginInstallRecord>;
5254
report: PluginStatusReport;
5355
}): {
5456
inspect: NonNullable<ReturnType<typeof buildPluginInspectReport>>;
@@ -74,12 +76,13 @@ function buildPluginInspectJson(params: {
7476
severity: warning.severity,
7577
message: formatPluginCompatibilityNotice(warning),
7678
})),
77-
install: params.config.plugins?.installs?.[inspect.plugin.id] ?? null,
79+
install: params.installRecords[inspect.plugin.id] ?? null,
7880
};
7981
}
8082

8183
function buildAllPluginInspectJson(params: {
8284
config: OpenClawConfig;
85+
installRecords: Record<string, PluginInstallRecord>;
8386
report: PluginStatusReport;
8487
}): Array<{
8588
inspect: ReturnType<typeof buildAllPluginInspectReports>[number];
@@ -100,7 +103,7 @@ function buildAllPluginInspectJson(params: {
100103
severity: warning.severity,
101104
message: formatPluginCompatibilityNotice(warning),
102105
})),
103-
install: params.config.plugins?.installs?.[inspect.plugin.id] ?? null,
106+
install: params.installRecords[inspect.plugin.id] ?? null,
104107
}));
105108
}
106109

@@ -413,6 +416,7 @@ export const handlePluginsCommand: CommandHandler = async (params, allowTextComm
413416
}
414417

415418
if (pluginsCommand.action === "inspect") {
419+
const installRecords = await loadPluginInstallRecords({ config: loaded.config });
416420
if (!pluginsCommand.name) {
417421
return {
418422
shouldContinue: false,
@@ -423,13 +427,17 @@ export const handlePluginsCommand: CommandHandler = async (params, allowTextComm
423427
return {
424428
shouldContinue: false,
425429
reply: {
426-
text: renderJsonBlock("🔌 Plugins", buildAllPluginInspectJson(loaded)),
430+
text: renderJsonBlock(
431+
"🔌 Plugins",
432+
buildAllPluginInspectJson({ ...loaded, installRecords }),
433+
),
427434
},
428435
};
429436
}
430437
const payload = buildPluginInspectJson({
431438
id: pluginsCommand.name,
432439
config: loaded.config,
440+
installRecords,
433441
report: loaded.report,
434442
});
435443
if (!payload) {

src/cli/plugins-cli-test-helpers.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,11 @@ export const listMarketplacePlugins: Mock<ListMarketplacePluginsFn> = vi.fn();
3232
export const resolveMarketplaceInstallShortcut: Mock<ResolveMarketplaceInstallShortcutFn> = vi.fn();
3333
export const enablePluginInConfig: UnknownMock = vi.fn();
3434
export const recordPluginInstall: UnknownMock = vi.fn();
35+
export const loadPluginInstallRecords: AsyncUnknownMock = vi.fn(async ({ config }) => {
36+
const cfg = config as OpenClawConfig | undefined;
37+
return structuredClone(cfg?.plugins?.installs ?? {});
38+
});
39+
export const writePersistedPluginInstallLedger: AsyncUnknownMock = vi.fn(async () => undefined);
3540
export const clearPluginManifestRegistryCache: UnknownMock = vi.fn();
3641
export const loadPluginManifestRegistry: UnknownMock = vi.fn();
3742
export const buildPluginSnapshotReport: UnknownMock = vi.fn();
@@ -151,6 +156,35 @@ vi.mock("../plugins/installs.js", () => ({
151156
)) as (typeof import("../plugins/installs.js"))["recordPluginInstall"],
152157
}));
153158

159+
vi.mock("../plugins/install-ledger-store.js", async (importOriginal) => {
160+
const actual = await importOriginal<typeof import("../plugins/install-ledger-store.js")>();
161+
return {
162+
...actual,
163+
loadPluginInstallRecords: ((...args: unknown[]) =>
164+
invokeMock<unknown[], unknown>(loadPluginInstallRecords, ...args)) as (
165+
...args: unknown[]
166+
) => unknown,
167+
writePersistedPluginInstallLedger: ((...args: unknown[]) =>
168+
invokeMock<unknown[], unknown>(writePersistedPluginInstallLedger, ...args)) as (
169+
...args: unknown[]
170+
) => unknown,
171+
recordPluginInstallInRecords: (
172+
records: Record<string, unknown>,
173+
update: { pluginId: string; installedAt?: string } & Record<string, unknown>,
174+
) => {
175+
const { pluginId, ...record } = update;
176+
return {
177+
...records,
178+
[pluginId]: {
179+
...(records[pluginId] as Record<string, unknown> | undefined),
180+
...record,
181+
installedAt: update.installedAt ?? "2026-04-25T00:00:00.000Z",
182+
},
183+
};
184+
},
185+
};
186+
});
187+
154188
vi.mock("../plugins/manifest-registry.js", () => ({
155189
clearPluginManifestRegistryCache: () => clearPluginManifestRegistryCache(),
156190
loadPluginManifestRegistry: ((...args: unknown[]) =>
@@ -424,6 +458,8 @@ export function resetPluginsCliTestState() {
424458
resolveMarketplaceInstallShortcut.mockReset();
425459
enablePluginInConfig.mockReset();
426460
recordPluginInstall.mockReset();
461+
loadPluginInstallRecords.mockReset();
462+
writePersistedPluginInstallLedger.mockReset();
427463
clearPluginManifestRegistryCache.mockReset();
428464
loadPluginManifestRegistry.mockReset();
429465
buildPluginSnapshotReport.mockReset();
@@ -482,6 +518,11 @@ export function resetPluginsCliTestState() {
482518
recordPluginInstall.mockImplementation(
483519
((cfg: OpenClawConfig) => cfg) as (...args: unknown[]) => unknown,
484520
);
521+
loadPluginInstallRecords.mockImplementation(async ({ config }) => {
522+
const cfg = config as OpenClawConfig | undefined;
523+
return structuredClone(cfg?.plugins?.installs ?? {});
524+
});
525+
writePersistedPluginInstallLedger.mockResolvedValue(undefined);
485526
loadPluginManifestRegistry.mockReturnValue({
486527
plugins: [],
487528
diagnostics: [],

src/cli/plugins-cli.ts

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,14 @@ import { resolveStateDir } from "../config/paths.js";
66
import type { OpenClawConfig } from "../config/types.openclaw.js";
77
import type { PluginInstallRecord } from "../config/types.plugins.js";
88
import { enablePluginInConfig } from "../plugins/enable.js";
9+
import {
10+
loadPluginInstallRecords,
11+
PLUGIN_INSTALLS_CONFIG_PATH,
12+
removePluginInstallRecordFromRecords,
13+
withoutPluginInstallRecords,
14+
writePersistedPluginInstallLedger,
15+
withPluginInstallRecords,
16+
} from "../plugins/install-ledger-store.js";
917
import { listMarketplacePlugins } from "../plugins/marketplace.js";
1018
import { inspectPluginRegistry, refreshPluginRegistry } from "../plugins/plugin-registry.js";
1119
import { defaultSlotIdForKey } from "../plugins/slots.js";
@@ -280,8 +288,9 @@ export function registerPluginsCli(program: Command) {
280288
.argument("[id]", "Plugin id")
281289
.option("--all", "Inspect all plugins")
282290
.option("--json", "Print JSON")
283-
.action((id: string | undefined, opts: PluginInspectOptions) => {
291+
.action(async (id: string | undefined, opts: PluginInspectOptions) => {
284292
const cfg = loadConfig();
293+
const installRecords = await loadPluginInstallRecords({ config: cfg });
285294
const report = buildPluginDiagnosticsReport({
286295
config: cfg,
287296
...(opts.json ? { logger: quietPluginJsonLogger } : {}),
@@ -298,7 +307,7 @@ export function registerPluginsCli(program: Command) {
298307
});
299308
const inspectAllWithInstall = inspectAll.map((inspect) => ({
300309
...inspect,
301-
install: cfg.plugins?.installs?.[inspect.plugin.id],
310+
install: installRecords[inspect.plugin.id],
302311
}));
303312

304313
if (opts.json) {
@@ -369,7 +378,7 @@ export function registerPluginsCli(program: Command) {
369378
defaultRuntime.error(`Plugin not found: ${id}`);
370379
return defaultRuntime.exit(1);
371380
}
372-
const install = cfg.plugins?.installs?.[inspect.plugin.id];
381+
const install = installRecords[inspect.plugin.id];
373382

374383
if (opts.json) {
375384
defaultRuntime.writeJson({
@@ -574,7 +583,9 @@ export function registerPluginsCli(program: Command) {
574583
.option("--dry-run", "Show what would be removed without making changes", false)
575584
.action(async (id: string, opts: PluginUninstallOptions) => {
576585
const snapshot = await readConfigFileSnapshot();
577-
const cfg = (snapshot.sourceConfig ?? snapshot.config) as OpenClawConfig;
586+
const sourceConfig = (snapshot.sourceConfig ?? snapshot.config) as OpenClawConfig;
587+
const installRecords = await loadPluginInstallRecords({ config: sourceConfig });
588+
const cfg = withPluginInstallRecords(sourceConfig, installRecords);
578589
const report = buildPluginDiagnosticsReport({ config: cfg });
579590
const extensionsDir = path.join(resolveStateDir(process.env, os.homedir), "extensions");
580591
const keepFiles = Boolean(opts.keepFiles || opts.keepConfig);
@@ -680,12 +691,16 @@ export function registerPluginsCli(program: Command) {
680691
defaultRuntime.log(theme.warn(warning));
681692
}
682693

694+
await writePersistedPluginInstallLedger(
695+
removePluginInstallRecordFromRecords(installRecords, pluginId),
696+
);
683697
await replaceConfigFile({
684-
nextConfig: result.config,
698+
nextConfig: withoutPluginInstallRecords(result.config),
685699
...(snapshot.hash !== undefined ? { baseHash: snapshot.hash } : {}),
700+
writeOptions: { unsetPaths: [Array.from(PLUGIN_INSTALLS_CONFIG_PATH)] },
686701
});
687702
await refreshPluginRegistryAfterConfigMutation({
688-
config: result.config,
703+
config: withoutPluginInstallRecords(result.config),
689704
reason: "source-changed",
690705
logger: {
691706
warn: (message) => defaultRuntime.log(theme.warn(message)),

src/cli/plugins-cli.uninstall.test.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
runtimeLogs,
1313
uninstallPlugin,
1414
writeConfigFile,
15+
writePersistedPluginInstallLedger,
1516
} from "./plugins-cli-test-helpers.js";
1617

1718
const CLI_STATE_ROOT = "/tmp/openclaw-state";
@@ -102,9 +103,18 @@ describe("plugins cli uninstall", () => {
102103
deleteFiles: false,
103104
}),
104105
);
105-
expect(writeConfigFile).toHaveBeenCalledWith(nextConfig);
106+
expect(writePersistedPluginInstallLedger).toHaveBeenCalledWith({});
107+
expect(writeConfigFile).toHaveBeenCalledWith({
108+
plugins: {
109+
entries: {},
110+
},
111+
});
106112
expect(refreshPluginRegistry).toHaveBeenCalledWith({
107-
config: nextConfig,
113+
config: {
114+
plugins: {
115+
entries: {},
116+
},
117+
},
108118
reason: "source-changed",
109119
});
110120
});

0 commit comments

Comments
 (0)