fix: defer onboarding plugin install records

This commit is contained in:
Shakker
2026-04-26 01:34:08 +01:00
parent 5edfbca6e5
commit 140ac29172
5 changed files with 100 additions and 31 deletions

View File

@@ -99,6 +99,7 @@ Docs: https://docs.openclaw.ai
Thanks @Kobe9312 and @vincentkoc.
- Plugins/install: restore the previous plugin index records if a concurrent config write conflict interrupts install, update, or uninstall metadata commits. Thanks @shakkernerd.
- Plugins/update: restore previous plugin index records if core update or channel setup hits a concurrent config write conflict after plugin metadata changes. Thanks @shakkernerd.
- Plugins/onboarding: defer channel/provider plugin install records until the owning config write commits, keeping setup failures from advancing the plugin index ahead of `openclaw.json`. Thanks @shakkernerd.
- Sessions: keep embedded runtime context out of the visible user prompt by
sending it as a hidden next-turn custom message, and teach doctor to repair
affected 2026.4.24 transcripts with duplicated prompt-rewrite branches.

View File

@@ -314,7 +314,13 @@ describe("ensureChannelSetupPluginInstalled", () => {
expect(result.installed).toBe(true);
expect(result.cfg.plugins?.entries?.["bundled-chat"]?.enabled).toBe(true);
expect(result.cfg.plugins?.allow).toContain("bundled-chat");
expect(result.cfg.plugins?.installs).toBeUndefined();
expect(result.cfg.plugins?.installs).toEqual({
"bundled-chat": expect.objectContaining({
source: "npm",
spec: bundledChatNpmSpec,
installPath: "/tmp/bundled-chat",
}),
});
expect(installPluginFromNpmSpec).toHaveBeenCalledWith(
expect.objectContaining({
expectedIntegrity: bundledChatIntegrity,

View File

@@ -1,6 +1,7 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { ChannelPluginCatalogEntry } from "../channels/plugins/catalog.js";
import type { ChannelPlugin } from "../channels/plugins/types.js";
import type { PluginInstallRecord } from "../config/types.plugins.js";
import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../plugins/runtime.js";
import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js";
import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js";
@@ -34,6 +35,10 @@ const registryRefreshMocks = vi.hoisted(() => ({
refreshPluginRegistryAfterConfigMutation: vi.fn(async () => undefined),
}));
const pluginInstallRecordCommitMocks = vi.hoisted(() => ({
commitPluginInstallRecordsWithConfig: vi.fn(),
}));
vi.mock("../channels/plugins/catalog.js", () => ({
listChannelPluginCatalogEntries: catalogMocks.listChannelPluginCatalogEntries,
}));
@@ -56,6 +61,8 @@ vi.mock("./channel-setup/plugin-install.js", () => pluginInstallMocks);
vi.mock("../cli/plugins-registry-refresh.js", () => registryRefreshMocks);
vi.mock("../cli/plugins-install-record-commit.js", () => pluginInstallRecordCommitMocks);
const runtime = createTestRuntime();
function listConfiguredAccountIds(
@@ -256,6 +263,12 @@ describe("channelsAddCommand", () => {
.mockImplementation(async (params: { nextConfig: unknown }) => {
await configMocks.writeConfigFile(params.nextConfig);
});
pluginInstallRecordCommitMocks.commitPluginInstallRecordsWithConfig.mockReset();
pluginInstallRecordCommitMocks.commitPluginInstallRecordsWithConfig.mockImplementation(
async (params: { nextConfig: unknown }) => {
await configMocks.writeConfigFile(params.nextConfig);
},
);
lifecycleMocks.onAccountConfigChanged.mockClear();
runtime.log.mockClear();
runtime.error.mockClear();
@@ -525,6 +538,60 @@ describe("channelsAddCommand", () => {
expectExternalChatEnabledConfigWrite();
});
it("commits channel setup plugin install records with the guarded config write", async () => {
configMocks.readConfigFileSnapshot.mockResolvedValue({
...baseConfigSnapshot,
hash: "config-1",
});
setActivePluginRegistry(createTestRegistry());
const catalogEntry = createExternalChatCatalogEntry();
catalogMocks.listChannelPluginCatalogEntries.mockReturnValue([catalogEntry]);
registerExternalChatSetupPlugin("external-chat");
const installRecords: Record<string, PluginInstallRecord> = {
"@vendor/external-chat-plugin": {
source: "npm",
spec: "@vendor/external-chat@1.2.3",
},
};
vi.mocked(ensureChannelSetupPluginInstalled).mockImplementation(async ({ cfg }) => ({
cfg: {
...cfg,
plugins: {
...cfg.plugins,
installs: installRecords,
},
},
installed: true,
pluginId: "@vendor/external-chat-plugin",
status: "installed",
}));
await channelsAddCommand(
{
channel: "external-chat",
account: "default",
token: "tenant-scoped",
},
runtime,
{ hasFlags: true },
);
expect(
pluginInstallRecordCommitMocks.commitPluginInstallRecordsWithConfig,
).toHaveBeenCalledWith({
nextInstallRecords: installRecords,
nextConfig: expect.not.objectContaining({
plugins: expect.objectContaining({ installs: expect.anything() }),
}),
baseHash: "config-1",
});
expect(registryRefreshMocks.refreshPluginRegistryAfterConfigMutation).toHaveBeenCalledWith(
expect.objectContaining({
installRecords,
}),
);
});
it("uses the installed plugin id when channel and plugin ids differ", async () => {
configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot });
setActivePluginRegistry(createTestRegistry());

View File

@@ -140,7 +140,13 @@ describe("ensureOnboardingPluginInstalled", () => {
);
expect(result.installed).toBe(true);
expect(result.status).toBe("installed");
expect(result.cfg.plugins?.installs).toBeUndefined();
expect(result.cfg.plugins?.installs).toEqual({
"demo-plugin": expect.objectContaining({
pluginId: "demo-plugin",
source: "npm",
spec: "@wecom/wecom-openclaw-plugin@1.2.3",
}),
});
expect(refreshPluginRegistryAfterConfigMutation).toHaveBeenCalledWith(
expect.objectContaining({
config: result.cfg,
@@ -467,7 +473,14 @@ describe("ensureOnboardingPluginInstalled", () => {
);
expect(result.installed).toBe(true);
expect(result.status).toBe("installed");
expect(result.cfg.plugins?.installs).toBeUndefined();
expect(result.cfg.plugins?.installs).toEqual({
"demo-plugin": {
pluginId: "demo-plugin",
source: "path",
sourcePath: "./plugins/demo",
spec: "@demo/plugin@1.2.3",
},
});
});
});
@@ -527,7 +540,14 @@ describe("ensureOnboardingPluginInstalled", () => {
);
expect(result.installed).toBe(true);
expect(result.status).toBe("installed");
expect(result.cfg.plugins?.installs).toBeUndefined();
expect(result.cfg.plugins?.installs).toEqual({
"demo-plugin": {
pluginId: "demo-plugin",
source: "path",
sourcePath: "./plugins/demo",
spec: "@demo/plugin@1.2.3",
},
});
},
);
});

View File

@@ -10,12 +10,6 @@ import {
} from "../plugins/bundled-sources.js";
import { enablePluginInConfig, type PluginEnableResult } from "../plugins/enable.js";
import { installPluginFromNpmSpec } from "../plugins/install.js";
import {
loadInstalledPluginIndexInstallRecords,
recordPluginInstallInRecords,
withoutPluginInstallRecords,
writePersistedInstalledPluginIndexInstallRecords,
} from "../plugins/installed-plugin-index-records.js";
import { buildNpmResolutionInstallFields, recordPluginInstall } from "../plugins/installs.js";
import type { PluginPackageInstall } from "../plugins/manifest.js";
import type { RuntimeEnv } from "../runtime.js";
@@ -142,17 +136,6 @@ function formatPortableLocalPath(localPath: string, workspaceDir?: string): stri
return undefined;
}
async function persistOnboardingPluginInstallRecord(params: {
cfg: OpenClawConfig;
install: Parameters<typeof recordPluginInstallInRecords>[1];
}) {
const records = await loadInstalledPluginIndexInstallRecords();
await writePersistedInstalledPluginIndexInstallRecords(
recordPluginInstallInRecords(records, params.install),
{ config: params.cfg },
);
}
async function refreshRegistryAfterOnboardingPluginInstall(params: {
cfg: OpenClawConfig;
refreshRegistry?: boolean;
@@ -184,11 +167,7 @@ async function recordLocalPluginInstall(params: {
...(sourcePath ? { sourcePath } : {}),
...(params.npmSpec ? { spec: params.npmSpec } : {}),
} as const;
await persistOnboardingPluginInstallRecord({
cfg: params.cfg,
install,
});
return withoutPluginInstallRecords(recordPluginInstall(params.cfg, install));
return recordPluginInstall(params.cfg, install);
}
function resolveLocalPath(params: {
@@ -599,11 +578,7 @@ export async function ensureOnboardingPluginInstalled(params: {
version: result.version,
...buildNpmResolutionInstallFields(result.npmResolution),
} as const;
await persistOnboardingPluginInstallRecord({
cfg: next,
install,
});
next = withoutPluginInstallRecords(recordPluginInstall(next, install));
next = recordPluginInstall(next, install);
await refreshRegistryAfterOnboardingPluginInstall({
cfg: next,
refreshRegistry: params.refreshRegistry,