diff --git a/CHANGELOG.md b/CHANGELOG.md index 154240d64b8..7b8a843e173 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -113,6 +113,7 @@ Docs: https://docs.openclaw.ai - 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. +- 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. - 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/cli/plugins-install-record-commit.test.ts b/src/cli/plugins-install-record-commit.test.ts new file mode 100644 index 00000000000..0d21c66900d --- /dev/null +++ b/src/cli/plugins-install-record-commit.test.ts @@ -0,0 +1,158 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import type { PluginInstallRecord } from "../config/types.plugins.js"; + +const mocks = vi.hoisted(() => ({ + loadInstalledPluginIndexInstallRecords: vi.fn(), + replaceConfigFile: vi.fn(), + writePersistedInstalledPluginIndexInstallRecords: vi.fn(), +})); + +vi.mock("../config/config.js", () => ({ + replaceConfigFile: mocks.replaceConfigFile, +})); + +vi.mock("../plugins/installed-plugin-index-records.js", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + loadInstalledPluginIndexInstallRecords: mocks.loadInstalledPluginIndexInstallRecords, + writePersistedInstalledPluginIndexInstallRecords: + mocks.writePersistedInstalledPluginIndexInstallRecords, + }; +}); + +import { commitConfigWithPendingPluginInstalls } from "./plugins-install-record-commit.js"; + +describe("commitConfigWithPendingPluginInstalls", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.loadInstalledPluginIndexInstallRecords.mockResolvedValue({}); + mocks.replaceConfigFile.mockResolvedValue(undefined); + mocks.writePersistedInstalledPluginIndexInstallRecords.mockResolvedValue(undefined); + }); + + it("moves pending plugin install records into the plugin index before writing stripped config", async () => { + const existingRecords: Record = { + existing: { + source: "npm", + spec: "existing@1.0.0", + }, + }; + const pendingRecords: Record = { + demo: { + source: "npm", + spec: "demo@1.0.0", + }, + }; + mocks.loadInstalledPluginIndexInstallRecords.mockResolvedValue(existingRecords); + const nextConfig: OpenClawConfig = { + plugins: { + entries: { + demo: { enabled: true }, + }, + installs: pendingRecords, + }, + }; + + const result = await commitConfigWithPendingPluginInstalls({ + nextConfig, + baseHash: "config-1", + }); + + expect(mocks.writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith({ + ...existingRecords, + ...pendingRecords, + }); + expect(mocks.replaceConfigFile).toHaveBeenCalledWith({ + nextConfig: { + plugins: { + entries: { + demo: { enabled: true }, + }, + }, + }, + baseHash: "config-1", + writeOptions: { + unsetPaths: [["plugins", "installs"]], + }, + }); + expect(result).toEqual({ + config: { + plugins: { + entries: { + demo: { enabled: true }, + }, + }, + }, + installRecords: { + ...existingRecords, + ...pendingRecords, + }, + movedInstallRecords: true, + }); + }); + + it("rolls back plugin index writes when the config write fails", async () => { + const existingRecords: Record = { + existing: { + source: "npm", + spec: "existing@1.0.0", + }, + }; + mocks.loadInstalledPluginIndexInstallRecords.mockResolvedValue(existingRecords); + mocks.replaceConfigFile.mockRejectedValue(new Error("config changed")); + + await expect( + commitConfigWithPendingPluginInstalls({ + nextConfig: { + plugins: { + installs: { + demo: { + source: "npm", + spec: "demo@1.0.0", + }, + }, + }, + }, + }), + ).rejects.toThrow("config changed"); + + expect(mocks.writePersistedInstalledPluginIndexInstallRecords).toHaveBeenNthCalledWith(1, { + existing: { + source: "npm", + spec: "existing@1.0.0", + }, + demo: { + source: "npm", + spec: "demo@1.0.0", + }, + }); + expect(mocks.writePersistedInstalledPluginIndexInstallRecords).toHaveBeenNthCalledWith( + 2, + existingRecords, + ); + }); + + it("uses a plain config write when no pending plugin install records exist", async () => { + const nextConfig: OpenClawConfig = { + gateway: { + mode: "local", + }, + }; + + const result = await commitConfigWithPendingPluginInstalls({ nextConfig }); + + expect(mocks.loadInstalledPluginIndexInstallRecords).not.toHaveBeenCalled(); + expect(mocks.writePersistedInstalledPluginIndexInstallRecords).not.toHaveBeenCalled(); + expect(mocks.replaceConfigFile).toHaveBeenCalledWith({ + nextConfig, + }); + expect(result).toEqual({ + config: nextConfig, + installRecords: {}, + movedInstallRecords: false, + }); + }); +}); diff --git a/src/cli/plugins-install-record-commit.ts b/src/cli/plugins-install-record-commit.ts index 65de5144bf0..3146cbe8df2 100644 --- a/src/cli/plugins-install-record-commit.ts +++ b/src/cli/plugins-install-record-commit.ts @@ -1,17 +1,28 @@ import { replaceConfigFile } from "../config/config.js"; +import type { ConfigWriteOptions } from "../config/io.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { PluginInstallRecord } from "../config/types.plugins.js"; import { loadInstalledPluginIndexInstallRecords, PLUGIN_INSTALLS_CONFIG_PATH, + withoutPluginInstallRecords, writePersistedInstalledPluginIndexInstallRecords, } from "../plugins/installed-plugin-index-records.js"; +function mergeUnsetPaths( + left?: ConfigWriteOptions["unsetPaths"], + right?: ConfigWriteOptions["unsetPaths"], +): ConfigWriteOptions["unsetPaths"] | undefined { + const merged = [...(left ?? []), ...(right ?? [])]; + return merged.length > 0 ? merged : undefined; +} + export async function commitPluginInstallRecordsWithConfig(params: { previousInstallRecords?: Record; nextInstallRecords: Record; nextConfig: OpenClawConfig; baseHash?: string; + writeOptions?: ConfigWriteOptions; }): Promise { const previousInstallRecords = params.previousInstallRecords ?? (await loadInstalledPluginIndexInstallRecords()); @@ -20,7 +31,12 @@ export async function commitPluginInstallRecordsWithConfig(params: { await replaceConfigFile({ nextConfig: params.nextConfig, ...(params.baseHash !== undefined ? { baseHash: params.baseHash } : {}), - writeOptions: { unsetPaths: [Array.from(PLUGIN_INSTALLS_CONFIG_PATH)] }, + writeOptions: { + ...params.writeOptions, + unsetPaths: mergeUnsetPaths(params.writeOptions?.unsetPaths, [ + Array.from(PLUGIN_INSTALLS_CONFIG_PATH), + ]), + }, }); } catch (error) { try { @@ -34,3 +50,46 @@ export async function commitPluginInstallRecordsWithConfig(params: { throw error; } } + +export async function commitConfigWithPendingPluginInstalls(params: { + nextConfig: OpenClawConfig; + baseHash?: string; + writeOptions?: ConfigWriteOptions; +}): Promise<{ + config: OpenClawConfig; + installRecords: Record; + movedInstallRecords: boolean; +}> { + const pendingInstallRecords = params.nextConfig.plugins?.installs ?? {}; + if (Object.keys(pendingInstallRecords).length === 0) { + await replaceConfigFile({ + nextConfig: params.nextConfig, + ...(params.baseHash !== undefined ? { baseHash: params.baseHash } : {}), + ...(params.writeOptions ? { writeOptions: params.writeOptions } : {}), + }); + return { + config: params.nextConfig, + installRecords: {}, + movedInstallRecords: false, + }; + } + + const previousInstallRecords = await loadInstalledPluginIndexInstallRecords(); + const nextInstallRecords = { + ...previousInstallRecords, + ...pendingInstallRecords, + }; + const strippedConfig = withoutPluginInstallRecords(params.nextConfig); + await commitPluginInstallRecordsWithConfig({ + previousInstallRecords, + nextInstallRecords, + nextConfig: strippedConfig, + ...(params.baseHash !== undefined ? { baseHash: params.baseHash } : {}), + ...(params.writeOptions ? { writeOptions: params.writeOptions } : {}), + }); + return { + config: strippedConfig, + installRecords: nextInstallRecords, + movedInstallRecords: true, + }; +} diff --git a/src/commands/agents.commands.add.ts b/src/commands/agents.commands.add.ts index e762c96204b..75c4fe53397 100644 --- a/src/commands/agents.commands.add.ts +++ b/src/commands/agents.commands.add.ts @@ -7,7 +7,7 @@ import { } from "../agents/agent-scope.js"; import { ensureAuthProfileStore } from "../agents/auth-profiles.js"; import { resolveAuthStorePath } from "../agents/auth-profiles/paths.js"; -import { replaceConfigFile } from "../config/config.js"; +import { commitConfigWithPendingPluginInstalls } from "../cli/plugins-install-record-commit.js"; import { logConfigUpdated } from "../config/logging.js"; import { DEFAULT_AGENT_ID, normalizeAgentId } from "../routing/session-key.js"; import { type RuntimeEnv, writeRuntimeJson } from "../runtime.js"; @@ -133,7 +133,7 @@ export async function agentsAddCommand( ? applyAgentBindings(nextConfig, bindingParse.bindings) : { config: nextConfig, added: [], updated: [], skipped: [], conflicts: [] }; - await replaceConfigFile({ + await commitConfigWithPendingPluginInstalls({ nextConfig: bindingResult.config, ...(baseHash !== undefined ? { baseHash } : {}), }); @@ -360,10 +360,11 @@ export async function agentsAddCommand( } } - await replaceConfigFile({ + const committed = await commitConfigWithPendingPluginInstalls({ nextConfig, ...(baseHash !== undefined ? { baseHash } : {}), }); + nextConfig = committed.config; logConfigUpdated(runtime); await ensureWorkspaceAndSessions(workspaceDir, runtime, { skipBootstrap: Boolean(nextConfig.agents?.defaults?.skipBootstrap), diff --git a/src/commands/configure.wizard.ts b/src/commands/configure.wizard.ts index 6c55f3785e6..236244be557 100644 --- a/src/commands/configure.wizard.ts +++ b/src/commands/configure.wizard.ts @@ -3,7 +3,8 @@ import nodePath from "node:path"; import { isDeepStrictEqual } from "node:util"; import { describeCodexNativeWebSearch } from "../agents/codex-native-web-search.shared.js"; import { formatCliCommand } from "../cli/command-format.js"; -import { readConfigFileSnapshot, replaceConfigFile, resolveGatewayPort } from "../config/config.js"; +import { commitConfigWithPendingPluginInstalls } from "../cli/plugins-install-record-commit.js"; +import { readConfigFileSnapshot, resolveGatewayPort } from "../config/config.js"; import { logConfigUpdated } from "../config/logging.js"; import { ConfigMutationConflictError } from "../config/mutate.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; @@ -457,10 +458,11 @@ export async function runConfigureWizard( command: opts.command, mode, }); - await replaceConfigFile({ + const committed = await commitConfigWithPendingPluginInstalls({ nextConfig: remoteConfig, ...(currentBaseHash !== undefined ? { baseHash: currentBaseHash } : {}), }); + remoteConfig = committed.config; currentBaseHash = undefined; logConfigUpdated(runtime); outro("Remote gateway configured."); @@ -496,10 +498,11 @@ export async function runConfigureWizard( const maxRetries = 3; for (let attempt = 0; attempt < maxRetries; attempt++) { try { - await replaceConfigFile({ + const committed = await commitConfigWithPendingPluginInstalls({ nextConfig, ...(currentBaseHash !== undefined ? { baseHash: currentBaseHash } : {}), }); + nextConfig = committed.config; // After successful write, re-read the snapshot to get the new hash const freshSnapshot = await readConfigFileSnapshot();