diff --git a/CHANGELOG.md b/CHANGELOG.md index cfd9efc2caa..2dac6e16f5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -143,6 +143,7 @@ Docs: https://docs.openclaw.ai - 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. - Plugins/config: route configure and agent setup writes with pending plugin install records through the plugin index commit helper so provider onboarding metadata is not stripped by plain config writes. Thanks @shakkernerd. +- Plugins/channels: merge pending channel plugin install records with the existing plugin index before config writes, preserving unrelated tracked installs during channel setup, resolve, remove, and capability repair flows. 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. diff --git a/src/commands/channels.add.test.ts b/src/commands/channels.add.test.ts index 644da5a9aab..a2ea8912b4a 100644 --- a/src/commands/channels.add.test.ts +++ b/src/commands/channels.add.test.ts @@ -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 { OpenClawConfig } from "../config/types.openclaw.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"; @@ -36,7 +37,7 @@ const registryRefreshMocks = vi.hoisted(() => ({ })); const pluginInstallRecordCommitMocks = vi.hoisted(() => ({ - commitPluginInstallRecordsWithConfig: vi.fn(), + commitConfigWithPendingPluginInstalls: vi.fn(), })); vi.mock("../channels/plugins/catalog.js", () => ({ @@ -263,10 +264,15 @@ describe("channelsAddCommand", () => { .mockImplementation(async (params: { nextConfig: unknown }) => { await configMocks.writeConfigFile(params.nextConfig); }); - pluginInstallRecordCommitMocks.commitPluginInstallRecordsWithConfig.mockReset(); - pluginInstallRecordCommitMocks.commitPluginInstallRecordsWithConfig.mockImplementation( + pluginInstallRecordCommitMocks.commitConfigWithPendingPluginInstalls.mockReset(); + pluginInstallRecordCommitMocks.commitConfigWithPendingPluginInstalls.mockImplementation( async (params: { nextConfig: unknown }) => { await configMocks.writeConfigFile(params.nextConfig); + return { + config: params.nextConfig, + installRecords: {}, + movedInstallRecords: false, + }; }, ); lifecycleMocks.onAccountConfigChanged.mockClear(); @@ -553,6 +559,18 @@ describe("channelsAddCommand", () => { spec: "@vendor/external-chat@1.2.3", }, }; + pluginInstallRecordCommitMocks.commitConfigWithPendingPluginInstalls.mockImplementationOnce( + async (params: { nextConfig: OpenClawConfig }) => { + const { installs: _installs, ...plugins } = params.nextConfig.plugins ?? {}; + const writtenConfig = { ...params.nextConfig, plugins }; + await configMocks.writeConfigFile(writtenConfig); + return { + config: writtenConfig, + installRecords, + movedInstallRecords: true, + }; + }, + ); vi.mocked(ensureChannelSetupPluginInstalled).mockImplementation(async ({ cfg }) => ({ cfg: { ...cfg, @@ -577,11 +595,10 @@ describe("channelsAddCommand", () => { ); expect( - pluginInstallRecordCommitMocks.commitPluginInstallRecordsWithConfig, + pluginInstallRecordCommitMocks.commitConfigWithPendingPluginInstalls, ).toHaveBeenCalledWith({ - nextInstallRecords: installRecords, - nextConfig: expect.not.objectContaining({ - plugins: expect.objectContaining({ installs: expect.anything() }), + nextConfig: expect.objectContaining({ + plugins: expect.objectContaining({ installs: installRecords }), }), baseHash: "config-1", }); diff --git a/src/commands/channels/add.ts b/src/commands/channels/add.ts index 426d40f5471..ccf49a45b1d 100644 --- a/src/commands/channels/add.ts +++ b/src/commands/channels/add.ts @@ -5,10 +5,9 @@ import { moveSingleAccountChannelSectionToDefaultAccount } from "../../channels/ import type { ChannelSetupPlugin } from "../../channels/plugins/setup-wizard-types.js"; import type { ChannelPlugin } from "../../channels/plugins/types.plugin.js"; import type { ChannelId, ChannelSetupInput } from "../../channels/plugins/types.public.js"; -import { commitPluginInstallRecordsWithConfig } from "../../cli/plugins-install-record-commit.js"; +import { commitConfigWithPendingPluginInstalls } from "../../cli/plugins-install-record-commit.js"; import { refreshPluginRegistryAfterConfigMutation } from "../../cli/plugins-registry-refresh.js"; -import { replaceConfigFile, type OpenClawConfig } from "../../config/config.js"; -import { withoutPluginInstallRecords } from "../../plugins/installed-plugin-index-records.js"; +import type { OpenClawConfig } from "../../config/config.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"; @@ -241,29 +240,16 @@ 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 commitPluginInstallRecordsWithConfig({ - nextInstallRecords: nextConfig.plugins?.installs ?? {}, - nextConfig: writtenConfig, - ...(baseHash !== undefined ? { baseHash } : {}), - }); - } else { - await replaceConfigFile({ - nextConfig: writtenConfig, - ...(baseHash !== undefined ? { baseHash } : {}), - }); - } - if (shouldMovePluginInstalls) { + const committed = await commitConfigWithPendingPluginInstalls({ + nextConfig, + ...(baseHash !== undefined ? { baseHash } : {}), + }); + const writtenConfig = committed.config; + if (committed.movedInstallRecords) { await refreshPluginRegistryAfterConfigMutation({ config: writtenConfig, reason: "source-changed", - installRecords: nextConfig.plugins?.installs ?? {}, + installRecords: committed.installRecords, logger: { warn: (message) => runtime.log(message) }, }); } @@ -395,29 +381,16 @@ 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 commitPluginInstallRecordsWithConfig({ - nextInstallRecords: nextConfig.plugins?.installs ?? {}, - nextConfig: writtenConfig, - ...(baseHash !== undefined ? { baseHash } : {}), - }); - } else { - await replaceConfigFile({ - nextConfig: writtenConfig, - ...(baseHash !== undefined ? { baseHash } : {}), - }); - } - if (shouldMovePluginInstalls || pluginRegistrySourceChanged) { + const committed = await commitConfigWithPendingPluginInstalls({ + nextConfig, + ...(baseHash !== undefined ? { baseHash } : {}), + }); + const writtenConfig = committed.config; + if (committed.movedInstallRecords || pluginRegistrySourceChanged) { await refreshPluginRegistryAfterConfigMutation({ config: writtenConfig, reason: "source-changed", - ...(shouldMovePluginInstalls ? { installRecords: nextConfig.plugins?.installs ?? {} } : {}), + ...(committed.movedInstallRecords ? { installRecords: committed.installRecords } : {}), logger: { warn: (message) => runtime.log(message) }, }); } @@ -440,7 +413,7 @@ export async function channelsAddCommand( }), }, ], - cfg: nextConfig, + cfg: writtenConfig, runtime, }); } diff --git a/src/commands/channels/capabilities.ts b/src/commands/channels/capabilities.ts index 91e1df2a581..6a622d5cf58 100644 --- a/src/commands/channels/capabilities.ts +++ b/src/commands/channels/capabilities.ts @@ -10,7 +10,7 @@ import type { ChannelCapabilitiesDisplayLine, ChannelPlugin, } from "../../channels/plugins/types.public.js"; -import { commitPluginInstallRecordsWithConfig } from "../../cli/plugins-install-record-commit.js"; +import { commitConfigWithPendingPluginInstalls } from "../../cli/plugins-install-record-commit.js"; import { refreshPluginRegistryAfterConfigMutation } from "../../cli/plugins-registry-refresh.js"; import { readConfigFileSnapshot, @@ -19,7 +19,6 @@ import { } from "../../config/config.js"; import { danger } from "../../globals.js"; import { formatErrorMessage } from "../../infra/errors.js"; -import { withoutPluginInstallRecords } from "../../plugins/installed-plugin-index-records.js"; import { defaultRuntime, type RuntimeEnv, writeRuntimeJson } from "../../runtime.js"; import { normalizeLowercaseStringOrEmpty, @@ -256,27 +255,30 @@ export async function channelsCapabilitiesCommand( const shouldMovePluginInstalls = Boolean( cfg.plugins?.installs && Object.keys(cfg.plugins.installs).length > 0, ); - const nextInstallRecords = cfg.plugins?.installs ?? {}; if (shouldMovePluginInstalls) { - cfg = withoutPluginInstallRecords(cfg); - await commitPluginInstallRecordsWithConfig({ - nextInstallRecords, + const committed = await commitConfigWithPendingPluginInstalls({ nextConfig: cfg, baseHash: (await sourceSnapshotPromise)?.hash, }); + cfg = committed.config; + await refreshPluginRegistryAfterConfigMutation({ + config: cfg, + reason: "source-changed", + installRecords: committed.installRecords, + logger: { warn: (message) => runtime.log(message) }, + }); } else { await replaceConfigFile({ nextConfig: cfg, baseHash: (await sourceSnapshotPromise)?.hash, }); - } - if (shouldMovePluginInstalls || resolved.pluginInstalled) { - await refreshPluginRegistryAfterConfigMutation({ - config: cfg, - reason: "source-changed", - ...(shouldMovePluginInstalls ? { installRecords: nextInstallRecords } : {}), - logger: { warn: (message) => runtime.log(message) }, - }); + if (resolved.pluginInstalled) { + await refreshPluginRegistryAfterConfigMutation({ + config: cfg, + reason: "source-changed", + logger: { warn: (message) => runtime.log(message) }, + }); + } } } return resolved.plugin ? [resolved.plugin] : null; diff --git a/src/commands/channels/remove.ts b/src/commands/channels/remove.ts index 44a92588e67..f826cc4eda5 100644 --- a/src/commands/channels/remove.ts +++ b/src/commands/channels/remove.ts @@ -4,10 +4,9 @@ import { listChannelPlugins, normalizeChannelId, } from "../../channels/plugins/index.js"; -import { commitPluginInstallRecordsWithConfig } from "../../cli/plugins-install-record-commit.js"; +import { commitConfigWithPendingPluginInstalls } from "../../cli/plugins-install-record-commit.js"; import { refreshPluginRegistryAfterConfigMutation } from "../../cli/plugins-registry-refresh.js"; import { replaceConfigFile, type OpenClawConfig } from "../../config/config.js"; -import { withoutPluginInstallRecords } from "../../plugins/installed-plugin-index-records.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"; @@ -177,27 +176,30 @@ export async function channelsRemoveCommand( const shouldMovePluginInstalls = Boolean( next.plugins?.installs && Object.keys(next.plugins.installs).length > 0, ); - const nextInstallRecords = next.plugins?.installs ?? {}; if (shouldMovePluginInstalls) { - next = withoutPluginInstallRecords(next); - await commitPluginInstallRecordsWithConfig({ - nextInstallRecords, + const committed = await commitConfigWithPendingPluginInstalls({ nextConfig: next, ...(baseHash !== undefined ? { baseHash } : {}), }); + next = committed.config; + await refreshPluginRegistryAfterConfigMutation({ + config: next, + reason: "source-changed", + installRecords: committed.installRecords, + logger: { warn: (message) => runtime.log(message) }, + }); } else { await replaceConfigFile({ nextConfig: next, ...(baseHash !== undefined ? { baseHash } : {}), }); - } - if (shouldMovePluginInstalls || resolvedPluginState?.pluginInstalled) { - await refreshPluginRegistryAfterConfigMutation({ - config: next, - reason: "source-changed", - ...(shouldMovePluginInstalls ? { installRecords: nextInstallRecords } : {}), - logger: { warn: (message) => runtime.log(message) }, - }); + if (resolvedPluginState?.pluginInstalled) { + await refreshPluginRegistryAfterConfigMutation({ + config: next, + reason: "source-changed", + logger: { warn: (message) => runtime.log(message) }, + }); + } } if (useWizard && prompter) { await prompter.outro( diff --git a/src/commands/channels/resolve.ts b/src/commands/channels/resolve.ts index bc9217314ca..fc09709c37f 100644 --- a/src/commands/channels/resolve.ts +++ b/src/commands/channels/resolve.ts @@ -5,12 +5,11 @@ import type { } from "../../channels/plugins/types.adapters.js"; import { resolveCommandConfigWithSecrets } from "../../cli/command-config-resolution.js"; import { getChannelsCommandSecretTargetIds } from "../../cli/command-secret-targets.js"; -import { commitPluginInstallRecordsWithConfig } from "../../cli/plugins-install-record-commit.js"; +import { commitConfigWithPendingPluginInstalls } from "../../cli/plugins-install-record-commit.js"; import { refreshPluginRegistryAfterConfigMutation } from "../../cli/plugins-registry-refresh.js"; import { loadConfig, readConfigFileSnapshot, replaceConfigFile } from "../../config/config.js"; import { danger } from "../../globals.js"; import { resolveMessageChannelSelection } from "../../infra/outbound/channel-selection.js"; -import { withoutPluginInstallRecords } from "../../plugins/installed-plugin-index-records.js"; import { type RuntimeEnv, writeRuntimeJson } from "../../runtime.js"; import { normalizeLowercaseStringOrEmpty, @@ -145,27 +144,30 @@ export async function channelsResolveCommand(opts: ChannelsResolveOptions, runti const shouldMovePluginInstalls = Boolean( cfg.plugins?.installs && Object.keys(cfg.plugins.installs).length > 0, ); - const nextInstallRecords = cfg.plugins?.installs ?? {}; if (shouldMovePluginInstalls) { - cfg = withoutPluginInstallRecords(cfg); - await commitPluginInstallRecordsWithConfig({ - nextInstallRecords, + const committed = await commitConfigWithPendingPluginInstalls({ nextConfig: cfg, baseHash: (await sourceSnapshotPromise)?.hash, }); + cfg = committed.config; + await refreshPluginRegistryAfterConfigMutation({ + config: cfg, + reason: "source-changed", + installRecords: committed.installRecords, + logger: { warn: (message) => runtime.log(message) }, + }); } else { await replaceConfigFile({ nextConfig: cfg, baseHash: (await sourceSnapshotPromise)?.hash, }); - } - if (shouldMovePluginInstalls || resolvedExplicit.pluginInstalled) { - await refreshPluginRegistryAfterConfigMutation({ - config: cfg, - reason: "source-changed", - ...(shouldMovePluginInstalls ? { installRecords: nextInstallRecords } : {}), - logger: { warn: (message) => runtime.log(message) }, - }); + if (resolvedExplicit.pluginInstalled) { + await refreshPluginRegistryAfterConfigMutation({ + config: cfg, + reason: "source-changed", + logger: { warn: (message) => runtime.log(message) }, + }); + } } }