fix(plugins): preserve install index state across failures (#119228)

* fix(plugins): preserve install index policy config

* fix(plugins): restore complete install index state

* fix(plugins): fence install index rollback

* fix(plugins): fence generic install index rollback

* fix(plugins): keep lifecycle lease context private

* test(cli): align plugin index rollback mocks

* test(plugins): enforce index rollback receipts

* fix(plugins): serialize install rollback with config commit

* test(plugins): keep legacy index writer mock private
This commit is contained in:
Vincent Koc
2026-08-05 01:04:17 +08:00
committed by GitHub
parent bfba83956f
commit ef11eae39b
17 changed files with 1314 additions and 242 deletions
+98 -9
View File
@@ -6,6 +6,7 @@ import { getRuntimeConfig } from "../config/config.js";
import type { HookInstallRecord } from "../config/types.hooks.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { PluginInstallRecord } from "../config/types.plugins.js";
import type { InstalledPluginIndex } from "../plugins/installed-plugin-index.js";
import type { CliMockOutputRuntime } from "./test-runtime-capture.js";
type UnknownMock = Mock<(...args: unknown[]) => unknown>;
@@ -27,6 +28,14 @@ type UpdateNpmInstalledPluginsFn =
(typeof import("../plugins/update.js"))["updateNpmInstalledPlugins"];
type UpdateNpmInstalledHookPacksFn =
(typeof import("../hooks/update.js"))["updateNpmInstalledHookPacks"];
type ReadPersistedInstalledPluginIndexFn =
(typeof import("../plugins/installed-plugin-index-store.js"))["readPersistedInstalledPluginIndex"];
type RestorePersistedInstalledPluginIndexIfCurrentFn =
(typeof import("../plugins/installed-plugin-index-store.js"))["restorePersistedInstalledPluginIndexIfCurrent"];
type WritePersistedInstalledPluginIndexInstallRecordsFn =
(typeof import("../plugins/installed-plugin-index-records.js"))["writePersistedInstalledPluginIndexInstallRecords"];
type WritePersistedInstalledPluginIndexInstallRecordsWithLeaseFn =
(typeof import("../plugins/installed-plugin-index-records.js"))["writePersistedInstalledPluginIndexInstallRecordsWithLease"];
type PluginInstallRecordMap = Record<string, PluginInstallRecord>;
function createEmptyUninstallActions() {
@@ -45,6 +54,7 @@ function createEmptyUninstallActions() {
let mockInstalledPluginIndexInstallRecords: PluginInstallRecordMap = {};
let mockHookInstallRecords: Record<string, HookInstallRecord> = {};
let mockInstalledPluginIndexRevision = 0;
export function setHookInstallRecords(records: Record<string, HookInstallRecord>): void {
mockHookInstallRecords = structuredClone(records);
@@ -55,6 +65,24 @@ function clonePluginInstallRecords(records: PluginInstallRecordMap): PluginInsta
return structuredClone(records);
}
export function createTestInstalledPluginIndex(params: {
policyHash: string;
installRecords: PluginInstallRecordMap;
}): InstalledPluginIndex {
return {
version: 1,
hostContractVersion: "test",
compatRegistryVersion: "test",
migrationVersion: 1,
policyHash: params.policyHash,
generatedAtMs: 0,
refreshReason: "source-changed",
installRecords: clonePluginInstallRecords(params.installRecords),
plugins: [],
diagnostics: [],
};
}
// oxlint-disable-next-line typescript/no-unnecessary-type-parameters -- Test helper preserves mock call and result types.
function invokeMock<TArgs extends unknown[], TResult>(mock: unknown, ...args: TArgs): TResult {
return (mock as (...args: TArgs) => TResult)(...args);
@@ -78,13 +106,31 @@ export const recordPluginInstall: UnknownMock = vi.fn();
const loadInstalledPluginIndexInstallRecords: AsyncUnknownMock = vi.fn(async () =>
clonePluginInstallRecords(mockInstalledPluginIndexInstallRecords),
);
export const writePersistedInstalledPluginIndexInstallRecords: AsyncUnknownMock = vi.fn(
async (records: unknown) => {
const writePersistedInstalledPluginIndexInstallRecords: Mock<WritePersistedInstalledPluginIndexInstallRecordsFn> =
vi.fn<WritePersistedInstalledPluginIndexInstallRecordsFn>(async (records) => {
mockInstalledPluginIndexInstallRecords = clonePluginInstallRecords(records);
return "/tmp/openclaw-state/openclaw.sqlite";
});
export const readPersistedInstalledPluginIndex: Mock<ReadPersistedInstalledPluginIndexFn> =
vi.fn<ReadPersistedInstalledPluginIndexFn>(async () => null);
export const writePersistedInstalledPluginIndexInstallRecordsWithLease: Mock<WritePersistedInstalledPluginIndexInstallRecordsWithLeaseFn> =
vi.fn<WritePersistedInstalledPluginIndexInstallRecordsWithLeaseFn>(async (records) => {
const previous = await readPersistedInstalledPluginIndex();
mockInstalledPluginIndexInstallRecords = clonePluginInstallRecords(records);
mockInstalledPluginIndexRevision += 1;
return { previous, revision: mockInstalledPluginIndexRevision };
});
export const restorePersistedInstalledPluginIndexIfCurrent: Mock<RestorePersistedInstalledPluginIndexIfCurrentFn> =
vi.fn<RestorePersistedInstalledPluginIndexIfCurrentFn>(async (index, expectedRevision) => {
if (mockInstalledPluginIndexRevision !== expectedRevision) {
return false;
}
mockInstalledPluginIndexInstallRecords = clonePluginInstallRecords(
(records ?? {}) as PluginInstallRecordMap,
(index?.installRecords ?? {}) as PluginInstallRecordMap,
);
},
);
mockInstalledPluginIndexRevision += 1;
return true;
});
export const loadPluginManifestRegistry: UnknownMock = vi.fn();
export const buildPluginSnapshotReport: UnknownMock = vi.fn();
export const buildPluginRegistrySnapshotReport: UnknownMock = vi.fn();
@@ -319,6 +365,11 @@ vi.mock("../plugins/installed-plugin-index-records.js", async (importOriginal) =
writePersistedInstalledPluginIndexInstallRecords,
...args,
)) as (...args: unknown[]) => unknown,
writePersistedInstalledPluginIndexInstallRecordsWithLease: ((...args: unknown[]) =>
invokeMock<unknown[], unknown>(
writePersistedInstalledPluginIndexInstallRecordsWithLease,
...args,
)) as (...args: unknown[]) => unknown,
recordPluginInstallInRecords: (
records: Record<string, unknown>,
update: { pluginId: string; installedAt?: string } & Record<string, unknown>,
@@ -336,6 +387,22 @@ vi.mock("../plugins/installed-plugin-index-records.js", async (importOriginal) =
};
});
vi.mock("../plugins/installed-plugin-index-store.js", async (importOriginal) => {
const actual =
await importOriginal<typeof import("../plugins/installed-plugin-index-store.js")>();
return {
...actual,
readPersistedInstalledPluginIndex: ((...args: unknown[]) =>
invokeMock<unknown[], unknown>(readPersistedInstalledPluginIndex, ...args)) as (
...args: unknown[]
) => unknown,
restorePersistedInstalledPluginIndexIfCurrent: ((...args: unknown[]) =>
invokeMock<unknown[], unknown>(restorePersistedInstalledPluginIndexIfCurrent, ...args)) as (
...args: unknown[]
) => unknown,
};
});
vi.mock("../plugins/manifest-registry.js", () => ({
loadPluginManifestRegistry: ((...args: unknown[]) =>
invokeMock<unknown[], unknown>(loadPluginManifestRegistry, ...args)) as (
@@ -751,8 +818,12 @@ export function resetPluginsCliTestState() {
enablePluginInConfig.mockReset();
recordPluginInstall.mockReset();
mockInstalledPluginIndexInstallRecords = {};
mockInstalledPluginIndexRevision = 0;
loadInstalledPluginIndexInstallRecords.mockReset();
writePersistedInstalledPluginIndexInstallRecords.mockReset();
writePersistedInstalledPluginIndexInstallRecordsWithLease.mockReset();
readPersistedInstalledPluginIndex.mockReset();
restorePersistedInstalledPluginIndexIfCurrent.mockReset();
loadPluginManifestRegistry.mockReset();
buildPluginSnapshotReport.mockReset();
buildPluginRegistrySnapshotReport.mockReset();
@@ -837,11 +908,29 @@ export function resetPluginsCliTestState() {
loadInstalledPluginIndexInstallRecords.mockImplementation(async () =>
clonePluginInstallRecords(mockInstalledPluginIndexInstallRecords),
);
writePersistedInstalledPluginIndexInstallRecords.mockImplementation(async (records: unknown) => {
mockInstalledPluginIndexInstallRecords = clonePluginInstallRecords(
(records ?? {}) as PluginInstallRecordMap,
);
writePersistedInstalledPluginIndexInstallRecords.mockImplementation(async (records) => {
mockInstalledPluginIndexInstallRecords = clonePluginInstallRecords(records);
return "/tmp/openclaw-state/openclaw.sqlite";
});
readPersistedInstalledPluginIndex.mockResolvedValue(null);
writePersistedInstalledPluginIndexInstallRecordsWithLease.mockImplementation(async (records) => {
const previous = await readPersistedInstalledPluginIndex();
mockInstalledPluginIndexInstallRecords = clonePluginInstallRecords(records);
mockInstalledPluginIndexRevision += 1;
return { previous, revision: mockInstalledPluginIndexRevision };
});
restorePersistedInstalledPluginIndexIfCurrent.mockImplementation(
async (index, expectedRevision) => {
if (mockInstalledPluginIndexRevision !== expectedRevision) {
return false;
}
mockInstalledPluginIndexInstallRecords = clonePluginInstallRecords(
(index?.installRecords ?? {}) as PluginInstallRecordMap,
);
mockInstalledPluginIndexRevision += 1;
return true;
},
);
loadPluginManifestRegistry.mockReturnValue({
plugins: [],
diagnostics: [],
+5 -5
View File
@@ -40,7 +40,7 @@ import {
runtimeErrors,
runtimeLogs,
writeConfigFile,
writePersistedInstalledPluginIndexInstallRecords,
writePersistedInstalledPluginIndexInstallRecordsWithLease,
} from "./plugins-cli-test-helpers.js";
const CLI_STATE_ROOT = "/tmp/openclaw-state";
@@ -430,10 +430,10 @@ function hookNpmInstallCall(callIndex = 0): PluginInstallCall {
}
function persistedInstallRecords(callIndex = 0): Record<string, PersistedInstallRecord> {
return mockCallArg(writePersistedInstalledPluginIndexInstallRecords, callIndex) as Record<
string,
PersistedInstallRecord
>;
return mockCallArg(
writePersistedInstalledPluginIndexInstallRecordsWithLease,
callIndex,
) as Record<string, PersistedInstallRecord>;
}
function persistedInstallRecord(pluginId: string, callIndex = 0): PersistedInstallRecord {
+52 -15
View File
@@ -10,19 +10,22 @@ import {
applyPluginUninstallDirectoryRemoval,
buildPluginDiagnosticsReport,
buildPluginSnapshotReport,
createTestInstalledPluginIndex,
loadConfig,
planPluginUninstall,
PromptInputClosedError,
promptYesNo,
readPersistedInstalledPluginIndex,
refreshPluginRegistry,
replaceConfigFile,
resetPluginsCliTestState,
restorePersistedInstalledPluginIndexIfCurrent,
runPluginsCommand,
runtimeErrors,
runtimeLogs,
setInstalledPluginIndexInstallRecords,
writeConfigFile,
writePersistedInstalledPluginIndexInstallRecords,
writePersistedInstalledPluginIndexInstallRecordsWithLease,
} from "./plugins-cli-test-helpers.js";
const CLI_STATE_ROOT = "/tmp/openclaw-state";
@@ -34,6 +37,17 @@ function expectRuntimeLogIncludes(fragment: string) {
expect(runtimeLogs.join("\n")).toContain(fragment);
}
function expectInstallRecordsWrittenWithLease(records: unknown, config: unknown) {
expect(writePersistedInstalledPluginIndexInstallRecordsWithLease).toHaveBeenCalledWith(
records,
expect.objectContaining({
config,
filePath: expect.any(String),
lease: expect.anything(),
}),
);
}
function expectLatestUninstallPlanParams(expected: {
pluginId: string;
deleteFiles: boolean;
@@ -186,7 +200,7 @@ describe("plugins cli uninstall", () => {
expect(promptYesNo).not.toHaveBeenCalled();
expectLatestUninstallPlanParams({ pluginId: "alpha", deleteFiles: false });
expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith({});
expectInstallRecordsWrittenWithLease({}, { plugins: { entries: {} } });
expect(writeConfigFile).toHaveBeenCalledWith({
plugins: {
entries: {},
@@ -267,9 +281,16 @@ describe("plugins cli uninstall", () => {
await runPluginsCommand(["plugins", "uninstall", "calendar", "--force", "--keep-files"]);
expectLatestUninstallPlanParams({ pluginId: "calendar", deleteFiles: false });
expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith({
"unrelated-plugin": { source: "npm", spec: "unrelated-plugin@1.0.0" },
});
expectInstallRecordsWrittenWithLease(
{
"unrelated-plugin": { source: "npm", spec: "unrelated-plugin@1.0.0" },
},
{
plugins: {
entries: { "unrelated-plugin": { enabled: true } },
},
},
);
});
it("rejects an ambiguous display name before planning or mutating installed plugins", async () => {
@@ -304,7 +325,7 @@ describe("plugins cli uninstall", () => {
expect(planPluginUninstall).not.toHaveBeenCalled();
expect(promptYesNo).not.toHaveBeenCalled();
expect(applyPluginUninstallDirectoryRemoval).not.toHaveBeenCalled();
expect(writePersistedInstalledPluginIndexInstallRecords).not.toHaveBeenCalled();
expect(writePersistedInstalledPluginIndexInstallRecordsWithLease).not.toHaveBeenCalled();
expect(writeConfigFile).not.toHaveBeenCalled();
expect(refreshPluginRegistry).not.toHaveBeenCalled();
});
@@ -368,7 +389,7 @@ describe("plugins cli uninstall", () => {
expectRuntimeLogIncludes('Warning: plugin "alpha" is referenced by Claw: @owner/audit-claw.');
expectRuntimeLogIncludes("Uninstalling it may break those Claws");
expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith({});
expectInstallRecordsWrittenWithLease({}, { plugins: { entries: {} } });
} finally {
if (previousStateDir === undefined) {
delete process.env.OPENCLAW_STATE_DIR;
@@ -424,7 +445,7 @@ describe("plugins cli uninstall", () => {
expect(runtimeErrors).toContain(
"Error: plugins uninstall requires confirmation input. Re-run in an interactive TTY or pass --force.",
);
expect(writePersistedInstalledPluginIndexInstallRecords).not.toHaveBeenCalled();
expect(writePersistedInstalledPluginIndexInstallRecordsWithLease).not.toHaveBeenCalled();
expect(writeConfigFile).not.toHaveBeenCalled();
expect(refreshPluginRegistry).not.toHaveBeenCalled();
expect(applyPluginUninstallDirectoryRemoval).not.toHaveBeenCalled();
@@ -452,9 +473,14 @@ describe("plugins cli uninstall", () => {
installs: {},
},
} as OpenClawConfig;
const previousPersistedIndex = createTestInstalledPluginIndex({
policyHash: "previous-policy",
installRecords,
});
loadConfig.mockReturnValue(baseConfig);
setInstalledPluginIndexInstallRecords(installRecords);
readPersistedInstalledPluginIndex.mockResolvedValue(previousPersistedIndex);
buildPluginSnapshotReport.mockReturnValue({
plugins: [{ id: "alpha", name: "alpha" }],
diagnostics: [],
@@ -480,10 +506,14 @@ describe("plugins cli uninstall", () => {
runPluginsCommand(["plugins", "uninstall", "alpha", "--force", "--keep-files"]),
).rejects.toThrow("config changed");
expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenNthCalledWith(1, {});
expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenNthCalledWith(
2,
installRecords,
expectInstallRecordsWrittenWithLease({}, { plugins: { entries: {} } });
expect(restorePersistedInstalledPluginIndexIfCurrent).toHaveBeenCalledWith(
previousPersistedIndex,
expect.any(Number),
expect.objectContaining({
filePath: expect.any(String),
lease: expect.anything(),
}),
);
expect(refreshPluginRegistry).not.toHaveBeenCalled();
expect(applyPluginUninstallDirectoryRemoval).not.toHaveBeenCalled();
@@ -613,7 +643,7 @@ describe("plugins cli uninstall", () => {
installs: installRecords,
},
});
expect(writePersistedInstalledPluginIndexInstallRecords).not.toHaveBeenCalled();
expect(writePersistedInstalledPluginIndexInstallRecordsWithLease).not.toHaveBeenCalled();
expect(refreshPluginRegistry).not.toHaveBeenCalled();
});
@@ -762,7 +792,14 @@ describe("plugins cli uninstall", () => {
await runPluginsCommand(["plugins", "uninstall", pluginId, "--force", "--keep-files"]);
expectLatestUninstallPlanParams({ pluginId, channelIds, deleteFiles: false });
expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith({});
expectInstallRecordsWrittenWithLease(
{},
expect.objectContaining({
channels: Object.fromEntries(
Object.entries(channels).filter(([channelId]) => !channelIds.includes(channelId)),
),
}),
);
expect(writeConfigFile).toHaveBeenCalledWith(
expect.objectContaining({
channels: Object.fromEntries(
@@ -837,7 +874,7 @@ describe("plugins cli uninstall", () => {
channelIds: undefined,
deleteFiles: false,
});
expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith({});
expectInstallRecordsWrittenWithLease({}, nextConfig);
expect(writeConfigFile).toHaveBeenCalledWith(nextConfig);
expectRuntimeLogIncludes("channel config (channels.alpha)");
expect(runtimeLogs.at(-2)).toContain('Uninstalled plugin "alpha"');
+66 -36
View File
@@ -8,13 +8,16 @@ import { resolveRegistryUpdateChannel } from "../infra/update-channels.js";
import { CLAWHUB_INSTALL_ERROR_CODE } from "../plugins/clawhub-error-codes.js";
import { VERSION } from "../version.js";
import {
createTestInstalledPluginIndex,
loadConfig,
notifyGatewayPluginMetadataChanged,
readConfigFileSnapshotForWrite,
readPersistedInstalledPluginIndex,
refreshPluginRegistry,
registerPluginsCli,
replaceConfigFile,
resetPluginsCliTestState,
restorePersistedInstalledPluginIndexIfCurrent,
runPluginsCommand,
runtimeErrors,
runtimeLogs,
@@ -23,7 +26,7 @@ import {
updateNpmInstalledHookPacks,
updateNpmInstalledPlugins,
writeConfigFile,
writePersistedInstalledPluginIndexInstallRecords,
writePersistedInstalledPluginIndexInstallRecordsWithLease,
} from "./plugins-cli-test-helpers.js";
const ORIGINAL_OPENCLAW_NIX_MODE = process.env.OPENCLAW_NIX_MODE;
@@ -81,6 +84,17 @@ function expectRestartNoticeLogged() {
).toBe(true);
}
function expectInstallRecordsWrittenWithLease(records: unknown, config: unknown) {
expect(writePersistedInstalledPluginIndexInstallRecordsWithLease).toHaveBeenCalledWith(
records,
expect.objectContaining({
config,
filePath: expect.any(String),
lease: expect.anything(),
}),
);
}
function expectSingleCallParams(mockFn: ReturnType<typeof vi.fn>) {
expect(mockFn).toHaveBeenCalledTimes(1);
const params = mockFn.mock.calls[0]?.[0] as Record<string, unknown> | undefined;
@@ -218,7 +232,7 @@ async function expectSkippedClawHubPluginUpdate(params: {
await expect(runPluginsCommand(["plugins", "update", "demo"])).rejects.toThrow("__exit__:1");
expect(writePersistedInstalledPluginIndexInstallRecords).not.toHaveBeenCalled();
expect(writePersistedInstalledPluginIndexInstallRecordsWithLease).not.toHaveBeenCalled();
expect(runtimeLogs.at(-1)).toContain(params.expectedLog);
}
@@ -301,7 +315,7 @@ describe("plugins cli update", () => {
expect(acquireLease).not.toHaveBeenCalled();
expect(writeConfigFile).not.toHaveBeenCalled();
expect(replaceConfigFile).not.toHaveBeenCalled();
expect(writePersistedInstalledPluginIndexInstallRecords).not.toHaveBeenCalled();
expect(writePersistedInstalledPluginIndexInstallRecordsWithLease).not.toHaveBeenCalled();
expect(refreshPluginRegistry).not.toHaveBeenCalled();
expect(runtimeLogs).toContain("Would update alpha: 1.0.0 -> 1.1.0.");
} finally {
@@ -591,9 +605,7 @@ describe("plugins cli update", () => {
expect(runtimeErrors).toEqual([]);
expect(updateNpmInstalledPlugins).toHaveBeenCalledOnce();
expect(updateNpmInstalledHookPacks).not.toHaveBeenCalled();
expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith(
nextConfig.plugins?.installs,
);
expectInstallRecordsWrittenWithLease(nextConfig.plugins?.installs, cfg);
expect(writeConfigFile).not.toHaveBeenCalled();
});
@@ -632,7 +644,7 @@ describe("plugins cli update", () => {
expect(runtimeErrors).toEqual([]);
expect(updateNpmInstalledPlugins).toHaveBeenCalledOnce();
expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith(pluginRecords);
expectInstallRecordsWrittenWithLease(pluginRecords, cfg);
expect(writeConfigFile).not.toHaveBeenCalled();
});
@@ -671,7 +683,7 @@ describe("plugins cli update", () => {
await runPluginsCommand(["plugins", "update", "brave"]);
expect(runtimeErrors).toEqual([]);
expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith(nextRecords);
expectInstallRecordsWrittenWithLease(nextRecords, sourceCfg);
expect(writeConfigFile).not.toHaveBeenCalled();
expect(replaceConfigFile).not.toHaveBeenCalled();
expect(refreshPluginRegistry).toHaveBeenCalledWith({
@@ -722,7 +734,11 @@ describe("plugins cli update", () => {
await runPluginsCommand(["plugins", "update", "brave"]);
expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith(nextRecords);
expectInstallRecordsWrittenWithLease(nextRecords, {
plugins: {
load: { paths: [nextInstallPath, customPath] },
},
});
expect(replaceConfigFile).toHaveBeenCalledWith({
nextConfig: {
plugins: {
@@ -783,18 +799,24 @@ describe("plugins cli update", () => {
.mockResolvedValueOnce(initialSnapshot)
.mockResolvedValueOnce(changedSnapshot);
const { previousRecords, nextRecords } = primeBravePluginRecordUpdate(cfg);
const previousPersistedIndex = createTestInstalledPluginIndex({
policyHash: "previous-policy",
installRecords: previousRecords,
});
readPersistedInstalledPluginIndex.mockResolvedValue(previousPersistedIndex);
await expect(runPluginsCommand(["plugins", "update", "brave"])).rejects.toThrow(
"config changed since last load",
);
expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenNthCalledWith(
1,
nextRecords,
);
expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenNthCalledWith(
2,
previousRecords,
expectInstallRecordsWrittenWithLease(nextRecords, cfg);
expect(restorePersistedInstalledPluginIndexIfCurrent).toHaveBeenCalledWith(
previousPersistedIndex,
expect.any(Number),
expect.objectContaining({
filePath: expect.any(String),
lease: expect.anything(),
}),
);
expect(writeConfigFile).not.toHaveBeenCalled();
expect(replaceConfigFile).not.toHaveBeenCalled();
@@ -839,18 +861,24 @@ describe("plugins cli update", () => {
.mockResolvedValueOnce(initialSnapshot)
.mockResolvedValueOnce(changedSnapshot);
const { previousRecords, nextRecords } = primeBravePluginRecordUpdate(cfg);
const previousPersistedIndex = createTestInstalledPluginIndex({
policyHash: "previous-policy",
installRecords: previousRecords,
});
readPersistedInstalledPluginIndex.mockResolvedValue(previousPersistedIndex);
await expect(runPluginsCommand(["plugins", "update", "brave"])).rejects.toThrow(
"included config changed since last load",
);
expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenNthCalledWith(
1,
nextRecords,
);
expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenNthCalledWith(
2,
previousRecords,
expectInstallRecordsWrittenWithLease(nextRecords, cfg);
expect(restorePersistedInstalledPluginIndexIfCurrent).toHaveBeenCalledWith(
previousPersistedIndex,
expect.any(Number),
expect.objectContaining({
filePath: expect.any(String),
lease: expect.anything(),
}),
);
expect(writeConfigFile).not.toHaveBeenCalled();
expect(replaceConfigFile).not.toHaveBeenCalled();
@@ -888,18 +916,24 @@ describe("plugins cli update", () => {
.mockResolvedValueOnce(initialSnapshot)
.mockResolvedValueOnce(invalidSnapshot);
const { previousRecords, nextRecords } = primeBravePluginRecordUpdate(cfg);
const previousPersistedIndex = createTestInstalledPluginIndex({
policyHash: "previous-policy",
installRecords: previousRecords,
});
readPersistedInstalledPluginIndex.mockResolvedValue(previousPersistedIndex);
await expect(runPluginsCommand(["plugins", "update", "brave"])).rejects.toThrow(
"invalid config for plugin brave",
);
expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenNthCalledWith(
1,
nextRecords,
);
expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenNthCalledWith(
2,
previousRecords,
expectInstallRecordsWrittenWithLease(nextRecords, cfg);
expect(restorePersistedInstalledPluginIndexIfCurrent).toHaveBeenCalledWith(
previousPersistedIndex,
expect.any(Number),
expect.objectContaining({
filePath: expect.any(String),
lease: expect.anything(),
}),
);
expect(writeConfigFile).not.toHaveBeenCalled();
expect(replaceConfigFile).not.toHaveBeenCalled();
@@ -1420,9 +1454,7 @@ describe("plugins cli update", () => {
expect(updateParams.config).toEqual(runtimeConfig);
expect(updateParams.pluginIds).toEqual(["alpha"]);
expect(updateParams.dryRun).toBe(false);
expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith(
nextConfig.plugins?.installs,
);
expectInstallRecordsWrittenWithLease(nextConfig.plugins?.installs, {});
expect(updateNpmInstalledHookPacks).not.toHaveBeenCalled();
expect(writeConfigFile).toHaveBeenCalledWith({});
expect(replaceConfigFile).toHaveBeenCalledWith({
@@ -1489,9 +1521,7 @@ describe("plugins cli update", () => {
await expect(runPluginsCommand(["plugins", "update", "--all"])).rejects.toThrow("__exit__:1");
expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith(
nextConfig.plugins?.installs,
);
expectInstallRecordsWrittenWithLease(nextConfig.plugins?.installs, {});
expect(refreshPluginRegistry).toHaveBeenCalledWith({
config: {},
installRecords: nextConfig.plugins?.installs,
+1
View File
@@ -437,6 +437,7 @@ async function runPluginUpdateCommandUnlocked(params: RunPluginUpdateCommandPara
await commitPluginInstallRecordsOnly({
previousInstallRecords: persistedPluginInstallRecords,
nextInstallRecords: nextPluginInstallRecords,
nextConfig,
verifyConfigFresh: async () => {
await assertRecordsOnlyUpdateConfigFresh({
baseHash: sourceSnapshot?.snapshot.hash,
+147 -1
View File
@@ -67,6 +67,16 @@ const updateNpmInstalledPlugins = vi.fn();
const loadInstalledPluginIndexInstallRecords = vi.fn(
async (params: { config?: OpenClawConfig } = {}) => params.config?.plugins?.installs ?? {},
);
const readPersistedInstalledPluginIndex = vi.fn(async () => null);
const restorePersistedInstalledPluginIndex = vi.fn(async () => undefined);
const restorePersistedInstalledPluginIndexIfCurrent = vi.fn<
typeof import("../plugins/installed-plugin-index-store.js").restorePersistedInstalledPluginIndexIfCurrent
>(async () => true);
const writePersistedInstalledPluginIndexInstallRecords = vi.fn(async () => undefined);
const writePersistedInstalledPluginIndexInstallRecordsWithLease = vi.fn(async () => ({
previous: null,
revision: 1,
}));
const checkShellCompletionStatus = vi.fn();
const ensureCompletionCacheExists = vi.fn();
const installCompletion = vi.fn();
@@ -292,7 +302,19 @@ vi.mock("../plugins/installed-plugin-index-records.js", async (importOriginal) =
return {
...actual,
loadInstalledPluginIndexInstallRecords,
writePersistedInstalledPluginIndexInstallRecords: vi.fn(async () => undefined),
writePersistedInstalledPluginIndexInstallRecords,
writePersistedInstalledPluginIndexInstallRecordsWithLease,
};
});
vi.mock("../plugins/installed-plugin-index-store.js", async (importOriginal) => {
const actual =
await importOriginal<typeof import("../plugins/installed-plugin-index-store.js")>();
return {
...actual,
readPersistedInstalledPluginIndex,
restorePersistedInstalledPluginIndex,
restorePersistedInstalledPluginIndexIfCurrent,
};
});
@@ -1310,6 +1332,14 @@ describe("update-cli", () => {
delete process.env[GATEWAY_SERVICE_RUNTIME_PID_ENV];
restartHealthTestControl.snapshot = undefined;
vi.clearAllMocks();
readPersistedInstalledPluginIndex.mockResolvedValue(null);
restorePersistedInstalledPluginIndex.mockResolvedValue(undefined);
restorePersistedInstalledPluginIndexIfCurrent.mockResolvedValue(true);
writePersistedInstalledPluginIndexInstallRecords.mockResolvedValue(undefined);
writePersistedInstalledPluginIndexInstallRecordsWithLease.mockResolvedValue({
previous: null,
revision: 1,
});
resetRuntimeCapture();
spawn.mockImplementation(() => {
const child = new EventEmitter() as EventEmitter & {
@@ -1777,6 +1807,21 @@ describe("update-cli", () => {
readPackageVersion.mockImplementation(async (pkgRoot: string) =>
pkgRoot === root ? "0.0.1" : "2026.5.28",
);
const preUpdateConfig = {
plugins: {
entries: {
msteams: { enabled: false },
},
},
} as OpenClawConfig;
vi.mocked(readConfigFileSnapshot).mockResolvedValue({
...baseSnapshot,
parsed: preUpdateConfig,
sourceConfig: preUpdateConfig,
resolved: preUpdateConfig,
config: preUpdateConfig,
runtimeConfig: preUpdateConfig,
});
const pluginInstallRecords = {
msteams: {
source: "npm",
@@ -1819,6 +1864,107 @@ describe("update-cli", () => {
integrity: "sha512-newer",
},
});
expect(writePersistedInstalledPluginIndexInstallRecordsWithLease).toHaveBeenCalledWith(
capturedRecords,
{
config: preUpdateConfig,
lease: expect.anything(),
},
);
expect(restorePersistedInstalledPluginIndexIfCurrent).not.toHaveBeenCalled();
});
it("restores the exact plugin index revision when post-core handoff fails", async () => {
const { root } = setupUpdatedRootRefresh();
readPackageVersion.mockImplementation(async (pkgRoot: string) =>
pkgRoot === root ? "0.0.1" : "2026.5.28",
);
const previousPersistedIndex = {
policyHash: "previous-policy",
installRecords: {
msteams: {
source: "npm",
spec: "@openclaw/msteams",
resolvedVersion: "1.0.0",
},
} satisfies Record<string, PluginInstallRecord>,
};
writePersistedInstalledPluginIndexInstallRecordsWithLease.mockResolvedValue({
previous: previousPersistedIndex as never,
revision: 17,
});
loadInstalledPluginIndexInstallRecords.mockResolvedValueOnce(
previousPersistedIndex.installRecords,
);
spawn.mockImplementationOnce(() => {
throw new Error("post-core spawn failed");
});
await expect(updateCommand({ yes: true, restart: false })).rejects.toThrow(
"post-core spawn failed",
);
expect(writePersistedInstalledPluginIndexInstallRecordsWithLease).toHaveBeenCalledTimes(1);
expect(restorePersistedInstalledPluginIndexIfCurrent).toHaveBeenCalledWith(
previousPersistedIndex,
17,
{ lease: expect.anything() },
);
});
it("keeps a child-committed plugin index when the post-core handoff is signaled", async () => {
const { root } = setupUpdatedRootRefresh();
readPackageVersion.mockImplementation(async (pkgRoot: string) =>
pkgRoot === root ? "0.0.1" : "2026.5.28",
);
const previousPersistedIndex = {
policyHash: "previous-policy",
installRecords: {
msteams: {
source: "npm",
spec: "@openclaw/msteams",
resolvedVersion: "1.0.0",
},
} satisfies Record<string, PluginInstallRecord>,
};
let currentRevision = 17;
writePersistedInstalledPluginIndexInstallRecordsWithLease.mockResolvedValue({
previous: previousPersistedIndex as never,
revision: currentRevision,
});
restorePersistedInstalledPluginIndexIfCurrent.mockImplementation(
async (_index, expectedRevision) => {
if (currentRevision !== expectedRevision) {
return false;
}
currentRevision += 1;
return true;
},
);
loadInstalledPluginIndexInstallRecords.mockResolvedValueOnce(
previousPersistedIndex.installRecords,
);
spawn.mockImplementationOnce(() => {
const child = new EventEmitter() as EventEmitter & {
once: EventEmitter["once"];
};
currentRevision = 18;
queueMicrotask(() => {
child.emit("exit", null, "SIGTERM");
});
return child;
});
await expect(updateCommand({ yes: true, restart: false })).rejects.toThrow(
"post-update process terminated by signal SIGTERM",
);
expect(restorePersistedInstalledPluginIndexIfCurrent).toHaveBeenCalledWith(
previousPersistedIndex,
17,
{ lease: expect.anything() },
);
expect(currentRevision).toBe(18);
});
it("respawns into the updated git root before requested channel persistence", async () => {
+36 -2
View File
@@ -43,8 +43,9 @@ import type { UpdateRunResult } from "../../infra/update-runner.js";
import { getWindowsSystem32ExePath } from "../../infra/windows-install-roots.js";
import {
loadInstalledPluginIndexInstallRecords,
writePersistedInstalledPluginIndexInstallRecords,
writePersistedInstalledPluginIndexInstallRecordsWithLease,
} from "../../plugins/installed-plugin-index-records.js";
import { restorePersistedInstalledPluginIndexIfCurrent } from "../../plugins/installed-plugin-index-store.js";
import { withPluginLifecycleLease } from "../../plugins/plugin-lifecycle-lease.js";
import { runExec } from "../../process/exec.js";
import { defaultRuntime } from "../../runtime.js";
@@ -513,10 +514,33 @@ export async function continuePostCoreUpdateInFreshProcess(params: {
records: params.pluginInstallRecords,
targetVersion: postCoreHostVersion,
});
let tentativePluginIndex:
| Awaited<ReturnType<typeof writePersistedInstalledPluginIndexInstallRecordsWithLease>>
| undefined;
const restoreTentativePluginIndex = async () => {
const tentative = tentativePluginIndex;
if (!tentative) {
return;
}
await withPluginLifecycleLease({}, async (lease) => {
await restorePersistedInstalledPluginIndexIfCurrent(tentative.previous, tentative.revision, {
lease,
});
});
tentativePluginIndex = undefined;
};
try {
if (pluginInstallRecords && pluginInstallRecords !== params.pluginInstallRecords) {
await writePersistedInstalledPluginIndexInstallRecords(pluginInstallRecords);
await withPluginLifecycleLease({}, async (lease) => {
tentativePluginIndex = await writePersistedInstalledPluginIndexInstallRecordsWithLease(
pluginInstallRecords,
{
...(params.preUpdateConfig ? { config: params.preUpdateConfig.sourceConfig } : {}),
lease,
},
);
});
}
await writePostCorePluginInstallRecordsFile(installRecordsPath, pluginInstallRecords);
await writePostCoreSourceConfigFile(sourceConfigPath, params.preUpdateConfig);
@@ -608,9 +632,19 @@ export async function continuePostCoreUpdateInFreshProcess(params: {
if (pluginUpdate) {
return { resumed: true, pluginUpdate };
}
await restoreTentativePluginIndex();
return { resumed: false, exitCode };
}
return { resumed: true, ...(pluginUpdate ? { pluginUpdate } : {}) };
} catch (error) {
try {
await restoreTentativePluginIndex();
} catch (rollbackError) {
throw new Error("Post-core update failed and could not restore the previous plugin index", {
cause: rollbackError,
});
}
throw error;
} finally {
await fs.rm(resultDir, { recursive: true, force: true }).catch(() => undefined);
}