mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:40:44 +00:00
feat(plugins): move install records to managed ledger
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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(),
|
||||
}));
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)] } }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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).",
|
||||
|
||||
@@ -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"];
|
||||
|
||||
197
src/plugins/install-ledger-store.test.ts
Normal file
197
src/plugins/install-ledger-store.test.ts
Normal 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" },
|
||||
});
|
||||
});
|
||||
});
|
||||
162
src/plugins/install-ledger-store.ts
Normal file
162
src/plugins/install-ledger-store.ts
Normal 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;
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user