fix(configure): preserve nested retry merges

This commit is contained in:
Vincent Koc
2026-04-15 10:58:13 +01:00
parent 400d4d26c2
commit 0c4003a5be
3 changed files with 109 additions and 24 deletions

View File

@@ -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

View File

@@ -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<string, unknown> };
expect((retryCall.nextConfig as Record<string, unknown>).ai).toBeDefined();
// Verify plugin-written nested config survived the retry merge.
const retryCall = mocks.replaceConfigFile.mock.calls[1][0] as {
nextConfig: Record<string, unknown>;
};
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",
},
},
},
},
});
});
});

View File

@@ -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<string, unknown> = { ...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;