From abf59205fcbd0513c0174cd190b729a42d08d607 Mon Sep 17 00:00:00 2001 From: Gio Della-Libera Date: Thu, 14 May 2026 14:35:15 -0700 Subject: [PATCH] fix(config): return persisted config write responses (#81445) Merged via squash. Prepared head SHA: 8f549e0621058b6db736bd2a436666f61f07d54a Co-authored-by: giodl73-repo <235387111+giodl73-repo@users.noreply.github.com> Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com> Reviewed-by: @altaywtf --- CHANGELOG.md | 1 + .../codex/src/migration/provider.test.ts | 1 + src/cli/plugins-install-record-commit.test.ts | 13 +++- src/cli/plugins-install-record-commit.ts | 31 ++++++---- src/commands/agents.bind.test-support.ts | 1 + src/config/config.ts | 2 + src/config/io.ts | 6 +- src/config/mutate.test.ts | 27 ++++++++ src/config/mutate.ts | 57 ++++++++++++----- .../server-methods/config-write-flow.ts | 7 ++- .../server-methods/config.shared-auth.test.ts | 62 ++++++++++++++++++- src/gateway/server-methods/config.ts | 10 +-- .../server-startup-config.recovery.test.ts | 5 +- src/gateway/server.config-patch.test.ts | 33 ++++++++++ src/gateway/test-helpers.config-runtime.ts | 4 ++ src/plugin-sdk/migration-runtime.test.ts | 2 + src/wizard/setup.ts | 2 +- 17 files changed, 221 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index efeef34697e..4f57079d3c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -159,6 +159,7 @@ Docs: https://docs.openclaw.ai - iMessage: stop sending visible `` placeholder text for media-only native image sends while preserving the internal echo key that prevents self-echo duplicate replies. (#81209) Thanks @homer-byte. - Agents/sessions: create configured agent main sessions before first `sessions_send` or gateway send, so agent-to-agent messages no longer fail when the target agent has not started yet. - Control UI/config: discard stale redacted placeholders from form-mode config saves while preserving restorable saved secrets, so unrelated settings changes no longer submit `__OPENCLAW_REDACTED__` as real data. Fixes #60917. Thanks @giodl73-repo and @BunsDev. +- Config: return the canonical persisted config from `config.set`, `config.apply`, and `config.patch` responses after write-time shaping. Fixes #77455. - gateway: pass Talk session scope to resolver [AI]. (#81379) Thanks @pgondhi987. - Gateway protocol: require v4 clients and stream explicit chat `deltaText`/`replace` frames so SDK clients can consume assistant updates without local diffing. (#80725) Thanks @samzong. - OpenAI plugin: clarify remote Codex OAuth login copy so tunneled users know sign-in may finish automatically before they paste the redirect URL. (#81301) Thanks @rubencu. diff --git a/extensions/codex/src/migration/provider.test.ts b/extensions/codex/src/migration/provider.test.ts index 13b4de0fc54..8b93e3ba215 100644 --- a/extensions/codex/src/migration/provider.test.ts +++ b/extensions/codex/src/migration/provider.test.ts @@ -1648,6 +1648,7 @@ function createConfigRuntime( return { path: "/tmp/openclaw.json", previousHash: null, + persistedHash: "test-persisted-hash", snapshot: {} as never, nextConfig: configState, afterWrite: { mode: "auto" }, diff --git a/src/cli/plugins-install-record-commit.test.ts b/src/cli/plugins-install-record-commit.test.ts index a746190554f..c8a867de3d0 100644 --- a/src/cli/plugins-install-record-commit.test.ts +++ b/src/cli/plugins-install-record-commit.test.ts @@ -32,7 +32,15 @@ describe("commitConfigWithPendingPluginInstalls", () => { beforeEach(() => { vi.clearAllMocks(); mocks.loadInstalledPluginIndexInstallRecords.mockResolvedValue({}); - mocks.replaceConfigFile.mockResolvedValue(undefined); + mocks.replaceConfigFile.mockImplementation(async (params: { nextConfig: OpenClawConfig }) => ({ + path: "/tmp/openclaw.json", + previousHash: null, + snapshot: {} as never, + nextConfig: params.nextConfig, + persistedHash: "test-config-hash", + afterWrite: { mode: "auto" }, + followUp: { mode: "auto", requiresRestart: false }, + })); mocks.writePersistedInstalledPluginIndexInstallRecords.mockResolvedValue(undefined); }); @@ -95,6 +103,7 @@ describe("commitConfigWithPendingPluginInstalls", () => { ...pendingRecords, }, movedInstallRecords: true, + persistedHash: "test-config-hash", }); }); @@ -184,6 +193,7 @@ describe("commitConfigWithPendingPluginInstalls", () => { config: nextConfig, installRecords: {}, movedInstallRecords: false, + persistedHash: "test-config-hash", }); }); @@ -205,6 +215,7 @@ describe("commitConfigWithPendingPluginInstalls", () => { config: nextConfig, installRecords: {}, movedInstallRecords: false, + persistedHash: null, }); }); }); diff --git a/src/cli/plugins-install-record-commit.ts b/src/cli/plugins-install-record-commit.ts index e8ff0172399..701dcac47d9 100644 --- a/src/cli/plugins-install-record-commit.ts +++ b/src/cli/plugins-install-record-commit.ts @@ -4,6 +4,7 @@ import { resolveConfigWriteAfterWrite, transformConfigFileWithRetry, type ConfigMutationCommit, + type ConfigReplaceResult, type ConfigMutationResult, type ConfigMutationContext, type ConfigTransformResult, @@ -27,7 +28,10 @@ function mergeUnsetPaths( return merged.length > 0 ? merged : undefined; } -type ConfigCommit = (config: OpenClawConfig, writeOptions?: ConfigWriteOptions) => Promise; +type ConfigCommit = ( + config: OpenClawConfig, + writeOptions?: ConfigWriteOptions, +) => Promise; const PLUGIN_SOURCE_CHANGED_RESTART_REASON = "plugin source changed"; function mergeAfterWrite( @@ -49,7 +53,7 @@ async function commitPluginInstallRecordsWithWriter(params: { nextConfig: OpenClawConfig; writeOptions?: ConfigWriteOptions; commit: ConfigCommit; -}): Promise { +}): Promise { const previousInstallRecords = params.previousInstallRecords ?? (await loadInstalledPluginIndexInstallRecords()); await writePersistedInstalledPluginIndexInstallRecords(params.nextInstallRecords); @@ -58,7 +62,7 @@ async function commitPluginInstallRecordsWithWriter(params: { previousInstallRecords, params.nextInstallRecords, ); - await params.commit(params.nextConfig, { + return await params.commit(params.nextConfig, { ...params.writeOptions, ...(installRecordsChanged && params.writeOptions?.afterWrite === undefined ? { afterWrite: { mode: "restart", reason: PLUGIN_SOURCE_CHANGED_RESTART_REASON } } @@ -90,7 +94,7 @@ export async function commitPluginInstallRecordsWithConfig(params: { await commitPluginInstallRecordsWithWriter({ ...params, commit: async (nextConfig, writeOptions) => { - await replaceConfigFile({ + return await replaceConfigFile({ nextConfig, ...(params.baseHash !== undefined ? { baseHash: params.baseHash } : {}), ...(writeOptions ? { writeOptions } : {}), @@ -107,18 +111,18 @@ export async function commitConfigWriteWithPendingPluginInstalls(params: { config: OpenClawConfig; installRecords: Record; movedInstallRecords: boolean; + persistedHash: string | null; }> { const pendingInstallRecords = params.nextConfig.plugins?.installs ?? {}; if (Object.keys(pendingInstallRecords).length === 0) { - if (params.writeOptions) { - await params.commit(params.nextConfig, params.writeOptions); - } else { - await params.commit(params.nextConfig); - } + const committed = params.writeOptions + ? await params.commit(params.nextConfig, params.writeOptions) + : await params.commit(params.nextConfig); return { config: params.nextConfig, installRecords: {}, movedInstallRecords: false, + persistedHash: committed?.persistedHash ?? null, }; } @@ -128,7 +132,7 @@ export async function commitConfigWriteWithPendingPluginInstalls(params: { ...pendingInstallRecords, }; const strippedConfig = withoutPluginInstallRecords(params.nextConfig); - await commitPluginInstallRecordsWithWriter({ + const committed = await commitPluginInstallRecordsWithWriter({ previousInstallRecords, nextInstallRecords, nextConfig: strippedConfig, @@ -139,6 +143,7 @@ export async function commitConfigWriteWithPendingPluginInstalls(params: { config: strippedConfig, installRecords: nextInstallRecords, movedInstallRecords: true, + persistedHash: committed?.persistedHash ?? null, }; } @@ -150,12 +155,13 @@ export async function commitConfigWithPendingPluginInstalls(params: { config: OpenClawConfig; installRecords: Record; movedInstallRecords: boolean; + persistedHash: string | null; }> { return await commitConfigWriteWithPendingPluginInstalls({ nextConfig: params.nextConfig, ...(params.writeOptions ? { writeOptions: params.writeOptions } : {}), commit: async (nextConfig, writeOptions) => { - await replaceConfigFile({ + return await replaceConfigFile({ nextConfig, ...(params.baseHash !== undefined ? { baseHash: params.baseHash } : {}), ...(writeOptions ? { writeOptions } : {}), @@ -173,7 +179,7 @@ export async function transformConfigWithPendingPluginInstalls( nextConfig, ...(writeOptions ? { writeOptions: mergeAfterWrite(writeOptions, params.afterWrite) } : {}), commit: async (config, commitWriteOptions) => { - await replaceConfigFile({ + return await replaceConfigFile({ nextConfig: config, snapshot, writeOptions: commitWriteOptions ?? {}, @@ -189,6 +195,7 @@ export async function transformConfigWithPendingPluginInstalls( ); return { config: committed.config, + persistedHash: committed.persistedHash, afterWrite, }; }; diff --git a/src/commands/agents.bind.test-support.ts b/src/commands/agents.bind.test-support.ts index 039f8a8f899..95b704f0c67 100644 --- a/src/commands/agents.bind.test-support.ts +++ b/src/commands/agents.bind.test-support.ts @@ -20,6 +20,7 @@ const replaceConfigFileMock: Mock<(...args: unknown[]) => Promise> = vi previousHash: null, snapshot: {} as never, nextConfig: params.nextConfig, + persistedHash: "test-config-hash", afterWrite: { mode: "auto" }, followUp: { mode: "auto", requiresRestart: false }, }; diff --git a/src/config/config.ts b/src/config/config.ts index c67e3021688..b226754e1bc 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -41,6 +41,7 @@ export type { } from "./runtime-snapshot.js"; export type { ConfigWriteNotification, + ConfigWriteResult, ReadConfigFileSnapshotWithPluginMetadataResult, } from "./io.js"; export { @@ -57,6 +58,7 @@ export type { ConfigMutationCommitResult, ConfigMutationContext, ConfigMutationIO, + ConfigReplaceResult, ConfigMutationResult, ConfigTransformResult, TransformConfigFileParams, diff --git a/src/config/io.ts b/src/config/io.ts index 9722ee47631..d16a7acfad7 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -181,6 +181,7 @@ type ConfigHealthState = { }; export type ParseConfigJson5Result = { ok: true; parsed: unknown } | { ok: false; error: string }; +export type ConfigWriteResult = { persistedHash: string; persistedConfig: OpenClawConfig }; export type ConfigWriteOptions = { /** * Read-time env snapshot used to validate `${VAR}` restoration decisions. @@ -2415,7 +2416,7 @@ export async function readSourceConfigSnapshotForWrite(): Promise { +): Promise { const io = createConfigIO(options.skipPluginValidation ? { pluginValidation: "skip" } : {}); assertConfigWriteAllowedInCurrentMode({ configPath: io.configPath }); let nextCfg = cfg; @@ -2451,7 +2452,7 @@ export async function writeConfigFile( !hadRuntimeSnapshot && !getRuntimeConfigSnapshotRefreshHandlerState() ) { - return; + return writeResult; } // Re-read the freshly persisted file so the sourceConfig we publish matches // exactly what readConfigFileSnapshot() will produce when the file-watcher @@ -2505,4 +2506,5 @@ export async function writeConfigFile( { cause }, ), }); + return { ...writeResult, persistedConfig: canonicalSourceConfig }; } diff --git a/src/config/mutate.test.ts b/src/config/mutate.test.ts index 64d392a0180..a30133dc3a4 100644 --- a/src/config/mutate.test.ts +++ b/src/config/mutate.test.ts @@ -361,6 +361,33 @@ describe("config mutate helpers", () => { ); }); + it("returns the canonical persisted config from replace writes", async () => { + const snapshot = createSnapshot({ + hash: "hash-persisted", + sourceConfig: { gateway: { auth: { mode: "token" } } }, + }); + ioMocks.writeConfigFile.mockResolvedValue({ + persistedHash: "hash-after", + persistedConfig: { + gateway: { auth: { mode: "token", token: "minted" } }, + meta: { lastTouchedVersion: "test" }, + }, + }); + + const result = await replaceConfigFile({ + baseHash: snapshot.hash, + nextConfig: { gateway: { auth: { mode: "token", token: "minted" } } }, + snapshot, + writeOptions: { expectedConfigPath: snapshot.path }, + }); + + expect(result.persistedHash).toBe("hash-after"); + expect(result.nextConfig).toEqual({ + gateway: { auth: { mode: "token", token: "minted" } }, + meta: { lastTouchedVersion: "test" }, + }); + }); + it("writes through a single-file top-level plugins include", async () => { const home = await suiteRootTracker.make("include"); const configPath = path.join(home, ".openclaw", "openclaw.json"); diff --git a/src/config/mutate.ts b/src/config/mutate.ts index ce2019d1139..cd0a056bf72 100644 --- a/src/config/mutate.ts +++ b/src/config/mutate.ts @@ -15,6 +15,7 @@ import { resolveConfigSnapshotHash, writeConfigFile, type ConfigWriteOptions, + type ConfigWriteResult, } from "./io.js"; import { applyUnsetPathsForWrite, resolveManagedUnsetPathsForWrite } from "./io.write-prepare.js"; import { assertConfigWriteAllowedInCurrentMode } from "./nix-mode-write-guard.js"; @@ -66,13 +67,17 @@ export type ConfigReplaceResult = { previousHash: string | null; snapshot: ConfigFileSnapshot; nextConfig: OpenClawConfig; + persistedHash: string | null; afterWrite: ConfigWriteAfterWrite; followUp: ConfigWriteFollowUp; }; export type ConfigMutationIO = { readConfigFileSnapshotForWrite: typeof readConfigFileSnapshotForWrite; - writeConfigFile: (cfg: OpenClawConfig, options?: ConfigWriteOptions) => Promise; + writeConfigFile: ( + cfg: OpenClawConfig, + options?: ConfigWriteOptions, + ) => Promise; }; export type ConfigMutationContext = { @@ -97,6 +102,7 @@ export type ConfigMutationCommitParams = { export type ConfigMutationCommitResult = { config: OpenClawConfig; + persistedHash: string | null; afterWrite?: ConfigWriteAfterWrite; }; @@ -236,27 +242,27 @@ async function tryWriteSingleTopLevelIncludeMutation(params: { afterWrite?: ConfigWriteOptions["afterWrite"]; writeOptions?: ConfigWriteOptions; io?: ConfigMutationIO; -}): Promise { +}): Promise<{ persistedHash: string | null; persistedConfig: OpenClawConfig } | null> { const nextConfig = applyUnsetPathsForWrite( params.nextConfig, resolveManagedUnsetPathsForWrite(params.writeOptions?.unsetPaths), ); const changedKeys = getChangedTopLevelKeys(params.snapshot.sourceConfig, nextConfig); if (changedKeys.length !== 1 || changedKeys[0] === "") { - return false; + return null; } const key = changedKeys[0]; const includePath = getSingleTopLevelIncludeTarget({ snapshot: params.snapshot, key }); if (!includePath || !isRecord(nextConfig) || !(key in nextConfig)) { - return false; + return null; } const nextConfigRecord = nextConfig as Record; if (params.writeOptions?.skipPluginValidation) { // Skip the include fast path so the root writer handles the write with // plugin validation disabled end-to-end (including the post-write readback). - return false; + return null; } const validated = validateConfigObjectWithPlugins(nextConfig); @@ -277,7 +283,7 @@ async function tryWriteSingleTopLevelIncludeMutation(params: { !hadRuntimeSnapshot && !getRuntimeConfigSnapshotRefreshHandler() ) { - return true; + return { persistedHash: null, persistedConfig: nextConfig }; } const refreshed = await ( @@ -325,7 +331,20 @@ async function tryWriteSingleTopLevelIncludeMutation(params: { { cause }, ), }); - return true; + return { persistedHash, persistedConfig: refreshedSnapshot.sourceConfig }; +} + +function resolveConfigWriteResult( + result: ConfigWriteResult | void, + fallbackConfig: OpenClawConfig, +): { persistedHash: string | null; persistedConfig: OpenClawConfig } { + if (result) { + return { + persistedHash: result.persistedHash, + persistedConfig: result.persistedConfig, + }; + } + return { persistedHash: null, persistedConfig: fallbackConfig }; } export async function replaceConfigFile(params: { @@ -361,26 +380,30 @@ async function replaceConfigFileUnlocked(params: { const afterWrite = resolveConfigWriteAfterWrite( params.afterWrite ?? params.writeOptions?.afterWrite, ); - const wroteInclude = await tryWriteSingleTopLevelIncludeMutation({ + let writeResult = await tryWriteSingleTopLevelIncludeMutation({ snapshot, nextConfig: params.nextConfig, afterWrite, writeOptions: params.writeOptions ?? writeOptions, io: params.io, }); - if (!wroteInclude) { - await (params.io?.writeConfigFile ?? writeConfigFile)(params.nextConfig, { - baseSnapshot: snapshot, - ...writeOptions, - ...params.writeOptions, - afterWrite, - }); + if (!writeResult) { + writeResult = resolveConfigWriteResult( + await (params.io?.writeConfigFile ?? writeConfigFile)(params.nextConfig, { + baseSnapshot: snapshot, + ...writeOptions, + ...params.writeOptions, + afterWrite, + }), + params.nextConfig, + ); } return { path: snapshot.path, previousHash, snapshot, - nextConfig: params.nextConfig, + nextConfig: writeResult.persistedConfig, + persistedHash: writeResult.persistedHash, afterWrite, followUp: resolveConfigWriteFollowUp(afterWrite), }; @@ -401,6 +424,7 @@ async function commitPreparedConfigMutation( }); return { config: result.nextConfig, + persistedHash: result.persistedHash, afterWrite: result.afterWrite, }; } @@ -438,6 +462,7 @@ async function transformConfigFileAttempt( previousHash, snapshot, nextConfig: committed.config, + persistedHash: committed.persistedHash, result: transformed.result, attempts: attempt + 1, afterWrite: committedAfterWrite, diff --git a/src/gateway/server-methods/config-write-flow.ts b/src/gateway/server-methods/config-write-flow.ts index deffd26af02..6319e3dc494 100644 --- a/src/gateway/server-methods/config-write-flow.ts +++ b/src/gateway/server-methods/config-write-flow.ts @@ -212,16 +212,17 @@ export async function commitGatewayConfigWrite(params: { nextConfig: OpenClawConfig; context?: GatewayRequestContext; disconnectSharedAuthClients?: boolean; -}): Promise<{ path: string; queueFollowUp: () => void }> { - await replaceConfigFile({ +}): Promise<{ path: string; config: OpenClawConfig; queueFollowUp: () => void }> { + const result = await replaceConfigFile({ nextConfig: params.nextConfig, writeOptions: params.writeOptions, afterWrite: { mode: "auto" }, }); return { path: resolveGatewayConfigPath(params.snapshot), + config: result.nextConfig, queueFollowUp: () => { - queueSharedGatewayAuthGenerationRefresh(true, params.nextConfig, params.context); + queueSharedGatewayAuthGenerationRefresh(true, result.nextConfig, params.context); queueSharedGatewayAuthDisconnect(Boolean(params.disconnectSharedAuthClients), params.context); }, }; diff --git a/src/gateway/server-methods/config.shared-auth.test.ts b/src/gateway/server-methods/config.shared-auth.test.ts index c1c308619ef..8a061cd3f16 100644 --- a/src/gateway/server-methods/config.shared-auth.test.ts +++ b/src/gateway/server-methods/config.shared-auth.test.ts @@ -9,6 +9,7 @@ import { const readConfigFileSnapshotForWriteMock = vi.fn(); const writeConfigFileMock = vi.fn(); +const persistedConfigResultMock = vi.fn((config: OpenClawConfig) => config); const validateConfigObjectWithPluginsMock = vi.fn(); const prepareSecretsRuntimeSnapshotMock = vi.fn(); const scheduleGatewaySigusr1RestartMock = vi.fn(() => ({ @@ -31,8 +32,19 @@ vi.mock("../../config/config.js", async () => { readConfigFileSnapshotForWrite: readConfigFileSnapshotForWriteMock, validateConfigObjectWithPlugins: validateConfigObjectWithPluginsMock, writeConfigFile: writeConfigFileMock, - replaceConfigFile: async (params: { nextConfig: unknown; writeOptions?: unknown }) => - await writeConfigFileMock(params.nextConfig, params.writeOptions), + replaceConfigFile: async (params: { nextConfig: OpenClawConfig; writeOptions?: unknown }) => { + await writeConfigFileMock(params.nextConfig, params.writeOptions); + const persistedConfig = persistedConfigResultMock(params.nextConfig); + return { + path: "/tmp/openclaw.json", + previousHash: "base-hash", + snapshot: createConfigWriteSnapshot(params.nextConfig), + nextConfig: persistedConfig, + persistedHash: "next-hash", + afterWrite: { mode: "auto" }, + followUp: { mode: "auto", requiresRestart: false }, + }; + }, }; }); @@ -76,9 +88,55 @@ beforeEach(() => { }), ); restartSentinelMocks.writeRestartSentinel.mockClear(); + persistedConfigResultMock.mockImplementation((config: OpenClawConfig) => config); }); describe("config shared auth disconnects", () => { + it("returns the persisted config from config.set write results", async () => { + const prevConfig: OpenClawConfig = { + gateway: { + port: 19000, + }, + }; + const submittedConfig: OpenClawConfig = { + gateway: { + port: 19001, + }, + }; + const persistedConfig: OpenClawConfig = { + gateway: { + port: 19001, + }, + meta: { + lastTouchedVersion: "test", + }, + }; + persistedConfigResultMock.mockReturnValueOnce(persistedConfig); + readConfigFileSnapshotForWriteMock.mockResolvedValue(createConfigWriteSnapshot(prevConfig)); + + const { options, respond } = createConfigHandlerHarness({ + method: "config.set", + params: { + raw: JSON.stringify(submittedConfig, null, 2), + baseHash: "base-hash", + }, + }); + + await configHandlers["config.set"](options); + await flushConfigHandlerMicrotasks(); + + expect(writeConfigFileMock).toHaveBeenCalledWith(submittedConfig, {}); + expect(respond).toHaveBeenCalledWith( + true, + { + ok: true, + path: "/tmp/openclaw.json", + config: persistedConfig, + }, + undefined, + ); + }); + it("does not disconnect shared-auth clients for config.set auth writes without restart", async () => { const prevConfig: OpenClawConfig = { gateway: { diff --git a/src/gateway/server-methods/config.ts b/src/gateway/server-methods/config.ts index 759d93c33ef..0069db547d9 100644 --- a/src/gateway/server-methods/config.ts +++ b/src/gateway/server-methods/config.ts @@ -340,7 +340,7 @@ export const configHandlers: GatewayRequestHandlers = { { ok: true, path: writeResult.path, - config: redactConfigObject(parsed.config, parsed.schema.uiHints), + config: redactConfigObject(writeResult.config, parsed.schema.uiHints), }, undefined, ); @@ -474,7 +474,7 @@ export const configHandlers: GatewayRequestHandlers = { mode: "config.patch", configPath: writeResult.path, changedPaths, - nextConfig: validated.config, + nextConfig: writeResult.config, actor, context, }); @@ -483,7 +483,7 @@ export const configHandlers: GatewayRequestHandlers = { { ok: true, path: writeResult.path, - config: redactConfigObject(validated.config, schemaPatch.uiHints), + config: redactConfigObject(writeResult.config, schemaPatch.uiHints), restart, sentinel: { path: sentinelPath, @@ -540,7 +540,7 @@ export const configHandlers: GatewayRequestHandlers = { mode: "config.apply", configPath: writeResult.path, changedPaths, - nextConfig: parsed.config, + nextConfig: writeResult.config, actor, context, }); @@ -549,7 +549,7 @@ export const configHandlers: GatewayRequestHandlers = { { ok: true, path: writeResult.path, - config: redactConfigObject(parsed.config, parsed.schema.uiHints), + config: redactConfigObject(writeResult.config, parsed.schema.uiHints), restart, sentinel: { path: sentinelPath, diff --git a/src/gateway/server-startup-config.recovery.test.ts b/src/gateway/server-startup-config.recovery.test.ts index 105963df8c0..b95cdb51e5e 100644 --- a/src/gateway/server-startup-config.recovery.test.ts +++ b/src/gateway/server-startup-config.recovery.test.ts @@ -155,7 +155,10 @@ function installConfigIoMockDefaults() { } return snapshot.valid ? { snapshot, pluginMetadataSnapshot } : { snapshot }; }); - writeConfig.mockResolvedValue(undefined); + writeConfig.mockResolvedValue({ + persistedHash: "test-persisted-hash", + persistedConfig: validConfig, + }); } describe("gateway startup config validation", () => { diff --git a/src/gateway/server.config-patch.test.ts b/src/gateway/server.config-patch.test.ts index b78a9016abb..4c3c0107934 100644 --- a/src/gateway/server.config-patch.test.ts +++ b/src/gateway/server.config-patch.test.ts @@ -167,6 +167,39 @@ describe("gateway config methods", () => { requireConfigObject(res.payload?.config, "updated config"); }); + it("returns the persisted config from config.set responses", async () => { + const current = await rpcReq<{ + hash?: string; + config?: Record; + }>(requireWs(), "config.get", {}); + expect(current.ok).toBe(true); + expect(typeof current.payload?.hash).toBe("string"); + const nextConfig = structuredClone( + requireConfigObject(current.payload?.config, "current config"), + ); + delete nextConfig.meta; + + const gateway = (nextConfig.gateway ??= {}) as Record; + gateway.port = 19001; + + const res = await rpcReq<{ + ok?: boolean; + config?: Record; + }>(requireWs(), "config.set", { + raw: JSON.stringify(nextConfig, null, 2), + baseHash: current.payload?.hash, + }); + expect(res.error).toBeUndefined(); + expect(res.ok).toBe(true); + + const after = await rpcReq<{ + config?: Record; + }>(requireWs(), "config.get", {}); + expect(after.ok).toBe(true); + expect(res.payload?.config).toEqual(after.payload?.config); + requireConfigObject(res.payload?.config, "response config"); + }); + it("redacts browser cdpUrl credentials from config.get responses", async () => { const { createConfigIO, resetConfigRuntimeState } = await import("../config/config.js"); const configPath = createConfigIO().configPath; diff --git a/src/gateway/test-helpers.config-runtime.ts b/src/gateway/test-helpers.config-runtime.ts index 07391504805..f6d81fbbe82 100644 --- a/src/gateway/test-helpers.config-runtime.ts +++ b/src/gateway/test-helpers.config-runtime.ts @@ -204,6 +204,10 @@ export function createGatewayConfigModuleMock(actual: GatewayConfigModule): Gate const raw = JSON.stringify(cfg, null, 2).trimEnd().concat("\n"); await fs.writeFile(configPath, raw, "utf-8"); actual.resetConfigRuntimeState(); + return { + persistedHash: "test-config-hash", + persistedConfig: composeTestConfig(cfg), + }; }); const readConfigFileSnapshotForWrite = diff --git a/src/plugin-sdk/migration-runtime.test.ts b/src/plugin-sdk/migration-runtime.test.ts index 5e440007107..6642b18b172 100644 --- a/src/plugin-sdk/migration-runtime.test.ts +++ b/src/plugin-sdk/migration-runtime.test.ts @@ -38,6 +38,7 @@ describe("withCachedMigrationConfigRuntime", () => { return { path: "/tmp/openclaw.json", previousHash: null, + persistedHash: "test-persisted-hash", snapshot: {} as never, nextConfig: runtimeConfig, afterWrite: { mode: "auto" }, @@ -52,6 +53,7 @@ describe("withCachedMigrationConfigRuntime", () => { return { path: "/tmp/openclaw.json", previousHash: null, + persistedHash: "test-persisted-hash", snapshot: {} as never, nextConfig: runtimeConfig, afterWrite: { mode: "auto" }, diff --git a/src/wizard/setup.ts b/src/wizard/setup.ts index f262daeb97f..5cdfe1ae6af 100644 --- a/src/wizard/setup.ts +++ b/src/wizard/setup.ts @@ -58,7 +58,7 @@ async function writeWizardConfigFile(config: OpenClawConfig): Promise { - await replaceConfigFile({ + return await replaceConfigFile({ nextConfig, writeOptions: { ...writeOptions, allowConfigSizeDrop: true }, afterWrite: { mode: "auto" },