From 0c4003a5befa86ed967886f747b0c381fac1eaba Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 15 Apr 2026 10:58:13 +0100 Subject: [PATCH] fix(configure): preserve nested retry merges --- CHANGELOG.md | 1 + src/commands/configure.wizard.test.ts | 101 ++++++++++++++++++++------ src/commands/configure.wizard.ts | 31 +++++++- 3 files changed, 109 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d6afc6facf..988b97f1972 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -212,6 +212,7 @@ Docs: https://docs.openclaw.ai - Cron/direct delivery: slim isolated-agent delivery cold paths so direct channel delivery and related cron execution spend less time loading unrelated auth, plugin, and channel runtime. Thanks @vincentkoc. - Channels/replay dedupe: standardize replay claims, retryable-failure release, and post-success commit behavior across Telegram, Discord, Slack, Mattermost, WhatsApp, Matrix, LINE, Feishu, Zalo, Nextcloud Talk, TLON, Nostr, Voice Call, and shared plugin interactive callbacks so duplicate deliveries stay reply-once after success but retry cleanly after pre-delivery failures. Thanks @vincentkoc. - Agents/OpenAI mini reasoning: remap unsupported `low` and `minimal` reasoning effort to `medium` for affected OpenAI mini models, and add a live regression lane to keep the compatibility fix covered. (#65478) Thanks @vincentkoc. +- Configure/wizard: replay wizard edits onto the latest config snapshot after a hash conflict so plugin-auth writes no longer get dropped during `openclaw configure`, including nested config under shared sections such as `plugins`. (#64188) Thanks @feiskyer and @vincentkoc. ## 2026.4.11 diff --git a/src/commands/configure.wizard.test.ts b/src/commands/configure.wizard.test.ts index 45479361c21..b3266c133d1 100644 --- a/src/commands/configure.wizard.test.ts +++ b/src/commands/configure.wizard.test.ts @@ -461,8 +461,20 @@ describe("runConfigureWizard", () => { expect(mocks.setupSearch).toHaveBeenCalledOnce(); }); - it("retries when plugin mutates config during wizard flow (issue #64188)", async () => { - setupBaseWizardState(); + it("retries without dropping nested plugin config written during wizard flow (issue #64188)", async () => { + const baseConfig: OpenClawConfig = { + plugins: { + entries: { + "github-copilot": { + enabled: false, + config: { + region: "us-east-1", + }, + }, + }, + }, + }; + setupBaseWizardState(baseConfig); queueWizardPrompts({ select: ["local"], confirm: [], @@ -475,33 +487,59 @@ describe("runConfigureWizard", () => { const newHashAfterMutation = "hash-after-plugin-mutation"; const finalHashAfterWrite = "hash-after-wizard-write"; - mocks.replaceConfigFile.mockImplementation(async (params: { nextConfig: unknown; baseHash?: string }) => { - callCount++; - if (callCount === 1) { - // First call: simulate plugin mutating config during promptAuthConfig - expect(params.baseHash).toBe(originalHash); - throw new ConfigMutationConflictError("config changed since last load", { - currentHash: newHashAfterMutation, - }); - } - // Second call: succeeds with refreshed hash - expect(params.baseHash).toBe(newHashAfterMutation); - await mocks.writeConfigFile(params); - }); + mocks.replaceConfigFile.mockImplementation( + async (params: { nextConfig: unknown; baseHash?: string }) => { + callCount++; + if (callCount === 1) { + // First call: simulate plugin mutating config during promptAuthConfig + expect(params.baseHash).toBe(originalHash); + throw new ConfigMutationConflictError("config changed since last load", { + currentHash: newHashAfterMutation, + }); + } + // Second call: succeeds with refreshed hash + expect(params.baseHash).toBe(newHashAfterMutation); + await mocks.writeConfigFile(params.nextConfig); + }, + ); // Mock readConfigFileSnapshot to return different hashes/configs on each call mocks.readConfigFileSnapshot .mockResolvedValueOnce({ ...EMPTY_CONFIG_SNAPSHOT, hash: originalHash, - config: {}, - sourceConfig: {}, + config: baseConfig, + sourceConfig: baseConfig, }) .mockResolvedValueOnce({ ...EMPTY_CONFIG_SNAPSHOT, hash: newHashAfterMutation, - config: { ai: { copilotToken: "plugin-wrote-this" } }, - sourceConfig: { ai: { copilotToken: "plugin-wrote-this" } }, + config: { + plugins: { + entries: { + "github-copilot": { + enabled: false, + config: { + region: "us-east-1", + accessToken: "plugin-wrote-this", + }, + }, + }, + }, + }, + sourceConfig: { + plugins: { + entries: { + "github-copilot": { + enabled: false, + config: { + region: "us-east-1", + accessToken: "plugin-wrote-this", + }, + }, + }, + }, + }, valid: true, }) .mockResolvedValueOnce({ @@ -518,8 +556,27 @@ describe("runConfigureWizard", () => { // Verify readConfigFileSnapshot was called: initial read, after conflict, after successful write expect(mocks.readConfigFileSnapshot).toHaveBeenCalledTimes(3); - // Verify plugin changes were merged into the retry call's nextConfig - const retryCall = mocks.replaceConfigFile.mock.calls[1][0] as { nextConfig: Record }; - expect((retryCall.nextConfig as Record).ai).toBeDefined(); + // Verify plugin-written nested config survived the retry merge. + const retryCall = mocks.replaceConfigFile.mock.calls[1][0] as { + nextConfig: Record; + }; + expect(retryCall.nextConfig).toMatchObject({ + agents: { + defaults: { + workspace: expect.stringContaining("/.openclaw/workspace"), + }, + }, + plugins: { + entries: { + "github-copilot": { + enabled: false, + config: { + region: "us-east-1", + accessToken: "plugin-wrote-this", + }, + }, + }, + }, + }); }); }); diff --git a/src/commands/configure.wizard.ts b/src/commands/configure.wizard.ts index 876b8a44824..e64b7cf2630 100644 --- a/src/commands/configure.wizard.ts +++ b/src/commands/configure.wizard.ts @@ -1,5 +1,6 @@ import fsPromises from "node:fs/promises"; 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"; @@ -11,7 +12,7 @@ import type { RuntimeEnv } from "../runtime.js"; import { defaultRuntime } from "../runtime.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; import { note } from "../terminal/note.js"; -import { resolveUserPath } from "../utils.js"; +import { isPlainObject, resolveUserPath } from "../utils.js"; import { createClackPrompter } from "../wizard/clack-prompter.js"; import { WizardCancelledError } from "../wizard/prompts.js"; import { resolveSetupSecretInputString } from "../wizard/setup.secret-input.js"; @@ -50,6 +51,26 @@ import { setupSkills } from "./onboard-skills.js"; type ConfigureSectionChoice = WizardSection | "__continue"; +function mergeWizardConfigOntoLatest(current: unknown, base: unknown, next: unknown): unknown { + if (isDeepStrictEqual(next, base)) { + return current; + } + if (isPlainObject(current) && isPlainObject(base) && isPlainObject(next)) { + const merged: Record = { ...current }; + const keys = new Set([...Object.keys(current), ...Object.keys(base), ...Object.keys(next)]); + for (const key of keys) { + const mergedValue = mergeWizardConfigOntoLatest(current[key], base[key], next[key]); + if (mergedValue === undefined) { + delete merged[key]; + } else { + merged[key] = mergedValue; + } + } + return merged; + } + return structuredClone(next); +} + async function resolveGatewaySecretInputForWizard(params: { cfg: OpenClawConfig; value: unknown; @@ -427,6 +448,7 @@ export async function runConfigureWizard( } let nextConfig = { ...baseConfig }; + let mergeBaseConfig = structuredClone(baseConfig); let didSetGatewayMode = false; if (nextConfig.gateway?.mode !== "local") { nextConfig = { @@ -462,6 +484,7 @@ export async function runConfigureWizard( // After successful write, re-read the snapshot to get the new hash const freshSnapshot = await readConfigFileSnapshot(); currentBaseHash = freshSnapshot.hash ?? undefined; + mergeBaseConfig = structuredClone(nextConfig); logConfigUpdated(runtime); return; @@ -475,7 +498,11 @@ export async function runConfigureWizard( const diskConfig = freshSnapshot.valid ? (freshSnapshot.sourceConfig ?? freshSnapshot.config) : {}; - nextConfig = { ...diskConfig, ...nextConfig }; + nextConfig = mergeWizardConfigOntoLatest( + diskConfig, + mergeBaseConfig, + nextConfig, + ) as OpenClawConfig; continue; } throw err;