diff --git a/src/config/io.runtime-snapshot-write.test.ts b/src/config/io.runtime-snapshot-write.test.ts index f223d63b86f..71ddbbb8de3 100644 --- a/src/config/io.runtime-snapshot-write.test.ts +++ b/src/config/io.runtime-snapshot-write.test.ts @@ -7,6 +7,7 @@ import { clearRuntimeConfigSnapshot, getRuntimeConfigSourceSnapshot, loadConfig, + setRuntimeConfigSnapshotRefreshHandler, setRuntimeConfigSnapshot, writeConfigFile, } from "./io.js"; @@ -41,6 +42,7 @@ function createRuntimeConfig(): OpenClawConfig { } function resetRuntimeConfigState(): void { + setRuntimeConfigSnapshotRefreshHandler(null); clearRuntimeConfigSnapshot(); clearConfigCache(); } @@ -169,4 +171,44 @@ describe("runtime config snapshot writes", () => { } }); }); + + it("keeps the last-known-good runtime snapshot active while a specialized refresh is pending", async () => { + await withTempHome("openclaw-config-runtime-refresh-pending-", async (home) => { + const configPath = path.join(home, ".openclaw", "openclaw.json"); + const sourceConfig = createSourceConfig(); + const runtimeConfig = createRuntimeConfig(); + const nextRuntimeConfig: OpenClawConfig = { + ...runtimeConfig, + gateway: { auth: { mode: "token" as const } }, + }; + + await fs.mkdir(path.dirname(configPath), { recursive: true }); + await fs.writeFile(configPath, `${JSON.stringify(sourceConfig, null, 2)}\n`, "utf8"); + + let releaseRefresh!: () => void; + const refreshPending = new Promise((resolve) => { + releaseRefresh = () => resolve(true); + }); + + try { + setRuntimeConfigSnapshot(runtimeConfig, sourceConfig); + setRuntimeConfigSnapshotRefreshHandler({ + refresh: async ({ sourceConfig: refreshedSource }) => { + expect(refreshedSource.gateway?.auth).toEqual({ mode: "token" }); + expect(loadConfig().gateway?.auth).toBeUndefined(); + return await refreshPending; + }, + }); + + const writePromise = writeConfigFile(nextRuntimeConfig); + await Promise.resolve(); + + expect(loadConfig().gateway?.auth).toBeUndefined(); + releaseRefresh(); + await writePromise; + } finally { + resetRuntimeConfigState(); + } + }); + }); }); diff --git a/src/config/io.ts b/src/config/io.ts index 7a24f0f9c9d..a4ec4cd430c 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -1425,6 +1425,7 @@ export async function writeConfigFile( ): Promise { const io = createConfigIO(); let nextCfg = cfg; + const hadRuntimeSnapshot = Boolean(runtimeConfigSnapshot); const hadBothSnapshots = Boolean(runtimeConfigSnapshot && runtimeConfigSourceSnapshot); if (hadBothSnapshots) { const runtimePatch = createMergePatch(runtimeConfigSnapshot!, cfg); @@ -1436,9 +1437,8 @@ export async function writeConfigFile( envSnapshotForRestore: sameConfigPath ? options.envSnapshotForRestore : undefined, unsetPaths: options.unsetPaths, }); - // Keep follow-up loadConfig() in sync with the just-persisted config (fixes race where a - // second connection's agents.update fails with "agent not found" after agents.create). - clearRuntimeConfigSnapshot(); + // Keep the last-known-good runtime snapshot active until the specialized refresh path + // succeeds, so concurrent readers do not observe unresolved SecretRefs mid-refresh. const refreshHandler = runtimeConfigSnapshotRefreshHandler; if (refreshHandler) { try { @@ -1460,11 +1460,15 @@ export async function writeConfigFile( } } if (hadBothSnapshots) { - // Refresh both snapshots from disk so follow-up reads get normalized config and + // Refresh both snapshots from disk atomically so follow-up reads get normalized config and // subsequent writes still get secret-preservation merge-patch (hadBothSnapshots stays true). - const fresh = loadConfig(); + const fresh = io.loadConfig(); setRuntimeConfigSnapshot(fresh, nextCfg); + return; } - // When we had no snapshot (or only one), do not set a new one: leave callers reading from - // disk/cache so external/manual edits to openclaw.json remain visible (no stale snapshot). + if (hadRuntimeSnapshot) { + clearRuntimeConfigSnapshot(); + } + // When we had no runtime snapshot, keep callers reading from disk/cache so external/manual + // edits to openclaw.json remain visible (no stale snapshot). }