Skip to content

Commit 4ff4ed7

Browse files
fix(config): refresh runtime snapshot from disk after write. Fixes #37175 (#37313)
Merged via squash. Prepared head SHA: 69e1861 Co-authored-by: bbblending <[email protected]> Co-authored-by: gumadeiras <[email protected]> Reviewed-by: @gumadeiras
1 parent 362248e commit 4ff4ed7

File tree

7 files changed

+516
-19
lines changed

7 files changed

+516
-19
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ Docs: https://docs.openclaw.ai
4545
- Podman/SELinux: auto-detect SELinux enforcing/permissive mode and add `:Z` relabel to bind mounts in `run-openclaw-podman.sh` and the Quadlet template, fixing `EACCES` on Fedora/RHEL hosts. Supports `OPENCLAW_BIND_MOUNT_OPTIONS` override. (#39449) Thanks @langdon and @githubbzxs.
4646
- TUI/theme: detect light terminal backgrounds via `COLORFGBG` and pick a WCAG AA-compliant light palette, with `OPENCLAW_THEME=light|dark` override for terminals without auto-detection. (#38636) Thanks @ademczuk and @vincentkoc.
4747
- Agents/context-engine plugins: bootstrap runtime plugins once at embedded-run, compaction, and subagent boundaries so plugin-provided context engines and hooks load from the active workspace before runtime resolution. (#40232)
48+
- Config/runtime snapshots: keep secrets-runtime-resolved config and auth-profile snapshots intact after config writes so follow-up reads still see file-backed secret values while picking up the persisted config update. (#37313) thanks @bbblending.
4849

4950
## 2026.3.7
5051

src/cli/daemon-cli/lifecycle.test.ts

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -36,16 +36,17 @@ const renderGatewayPortHealthDiagnostics = vi.fn(() => ["diag: unhealthy port"])
3636
const renderRestartDiagnostics = vi.fn(() => ["diag: unhealthy runtime"]);
3737
const resolveGatewayPort = vi.fn(() => 18789);
3838
const findGatewayPidsOnPortSync = vi.fn<(port: number) => number[]>(() => []);
39-
const probeGateway = vi.fn<
40-
(opts: {
41-
url: string;
42-
auth?: { token?: string; password?: string };
43-
timeoutMs: number;
44-
}) => Promise<{
45-
ok: boolean;
46-
configSnapshot: unknown;
47-
}>
48-
>();
39+
const probeGateway =
40+
vi.fn<
41+
(opts: {
42+
url: string;
43+
auth?: { token?: string; password?: string };
44+
timeoutMs: number;
45+
}) => Promise<{
46+
ok: boolean;
47+
configSnapshot: unknown;
48+
}>
49+
>();
4950
const isRestartEnabled = vi.fn<(config?: { commands?: unknown }) => boolean>(() => true);
5051
const loadConfig = vi.fn(() => ({}));
5152

src/config/config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export {
22
clearConfigCache,
3+
ConfigRuntimeRefreshError,
34
clearRuntimeConfigSnapshot,
45
createConfigIO,
56
getRuntimeConfigSnapshot,
@@ -10,6 +11,7 @@ export {
1011
readConfigFileSnapshot,
1112
readConfigFileSnapshotForWrite,
1213
resolveConfigSnapshotHash,
14+
setRuntimeConfigSnapshotRefreshHandler,
1315
setRuntimeConfigSnapshot,
1416
writeConfigFile,
1517
} from "./io.js";

src/config/io.runtime-snapshot-write.test.ts

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
clearRuntimeConfigSnapshot,
88
getRuntimeConfigSourceSnapshot,
99
loadConfig,
10+
setRuntimeConfigSnapshotRefreshHandler,
1011
setRuntimeConfigSnapshot,
1112
writeConfigFile,
1213
} from "./io.js";
@@ -41,6 +42,7 @@ function createRuntimeConfig(): OpenClawConfig {
4142
}
4243

4344
function resetRuntimeConfigState(): void {
45+
setRuntimeConfigSnapshotRefreshHandler(null);
4446
clearRuntimeConfigSnapshot();
4547
clearConfigCache();
4648
}
@@ -96,4 +98,117 @@ describe("runtime config snapshot writes", () => {
9698
}
9799
});
98100
});
101+
102+
it("refreshes the runtime snapshot after writes so follow-up reads see persisted changes", async () => {
103+
await withTempHome("openclaw-config-runtime-write-refresh-", async (home) => {
104+
const configPath = path.join(home, ".openclaw", "openclaw.json");
105+
const sourceConfig: OpenClawConfig = {
106+
models: {
107+
providers: {
108+
openai: {
109+
baseUrl: "https://api.openai.com/v1",
110+
apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
111+
models: [],
112+
},
113+
},
114+
},
115+
};
116+
const runtimeConfig: OpenClawConfig = {
117+
models: {
118+
providers: {
119+
openai: {
120+
baseUrl: "https://api.openai.com/v1",
121+
apiKey: "sk-runtime-resolved", // pragma: allowlist secret
122+
models: [],
123+
},
124+
},
125+
},
126+
};
127+
const nextRuntimeConfig: OpenClawConfig = {
128+
...runtimeConfig,
129+
gateway: { auth: { mode: "token" as const } },
130+
};
131+
132+
await fs.mkdir(path.dirname(configPath), { recursive: true });
133+
await fs.writeFile(configPath, `${JSON.stringify(sourceConfig, null, 2)}\n`, "utf8");
134+
135+
try {
136+
setRuntimeConfigSnapshot(runtimeConfig, sourceConfig);
137+
expect(loadConfig().gateway?.auth).toBeUndefined();
138+
139+
await writeConfigFile(nextRuntimeConfig);
140+
141+
expect(loadConfig().gateway?.auth).toEqual({ mode: "token" });
142+
expect(loadConfig().models?.providers?.openai?.apiKey).toBeDefined();
143+
144+
let persisted = JSON.parse(await fs.readFile(configPath, "utf8")) as {
145+
gateway?: { auth?: unknown };
146+
models?: { providers?: { openai?: { apiKey?: unknown } } };
147+
};
148+
expect(persisted.gateway?.auth).toEqual({ mode: "token" });
149+
// Post-write secret-ref: apiKey must stay as source ref (not plaintext).
150+
expect(persisted.models?.providers?.openai?.apiKey).toEqual({
151+
source: "env",
152+
provider: "default",
153+
id: "OPENAI_API_KEY",
154+
});
155+
156+
// Follow-up write: runtimeConfigSourceSnapshot must be restored so second write
157+
// still runs secret-preservation merge-patch and keeps apiKey as ref (not plaintext).
158+
await writeConfigFile(loadConfig());
159+
persisted = JSON.parse(await fs.readFile(configPath, "utf8")) as {
160+
gateway?: { auth?: unknown };
161+
models?: { providers?: { openai?: { apiKey?: unknown } } };
162+
};
163+
expect(persisted.models?.providers?.openai?.apiKey).toEqual({
164+
source: "env",
165+
provider: "default",
166+
id: "OPENAI_API_KEY",
167+
});
168+
} finally {
169+
clearRuntimeConfigSnapshot();
170+
clearConfigCache();
171+
}
172+
});
173+
});
174+
175+
it("keeps the last-known-good runtime snapshot active while a specialized refresh is pending", async () => {
176+
await withTempHome("openclaw-config-runtime-refresh-pending-", async (home) => {
177+
const configPath = path.join(home, ".openclaw", "openclaw.json");
178+
const sourceConfig = createSourceConfig();
179+
const runtimeConfig = createRuntimeConfig();
180+
const nextRuntimeConfig: OpenClawConfig = {
181+
...runtimeConfig,
182+
gateway: { auth: { mode: "token" as const } },
183+
};
184+
185+
await fs.mkdir(path.dirname(configPath), { recursive: true });
186+
await fs.writeFile(configPath, `${JSON.stringify(sourceConfig, null, 2)}\n`, "utf8");
187+
188+
let releaseRefresh!: () => void;
189+
const refreshPending = new Promise<boolean>((resolve) => {
190+
releaseRefresh = () => resolve(true);
191+
});
192+
193+
try {
194+
setRuntimeConfigSnapshot(runtimeConfig, sourceConfig);
195+
setRuntimeConfigSnapshotRefreshHandler({
196+
refresh: async ({ sourceConfig: refreshedSource }) => {
197+
expect(refreshedSource.gateway?.auth).toEqual({ mode: "token" });
198+
expect(loadConfig().gateway?.auth).toBeUndefined();
199+
return await refreshPending;
200+
},
201+
});
202+
203+
const writePromise = writeConfigFile(nextRuntimeConfig);
204+
await Promise.resolve();
205+
206+
expect(loadConfig().gateway?.auth).toBeUndefined();
207+
releaseRefresh();
208+
await writePromise;
209+
} finally {
210+
resetRuntimeConfigState();
211+
}
212+
});
213+
});
99214
});

src/config/io.ts

Lines changed: 62 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,22 @@ export type ReadConfigFileSnapshotForWriteResult = {
140140
writeOptions: ConfigWriteOptions;
141141
};
142142

143+
export type RuntimeConfigSnapshotRefreshParams = {
144+
sourceConfig: OpenClawConfig;
145+
};
146+
147+
export type RuntimeConfigSnapshotRefreshHandler = {
148+
refresh: (params: RuntimeConfigSnapshotRefreshParams) => boolean | Promise<boolean>;
149+
clearOnRefreshFailure?: () => void;
150+
};
151+
152+
export class ConfigRuntimeRefreshError extends Error {
153+
constructor(message: string, options?: { cause?: unknown }) {
154+
super(message, options);
155+
this.name = "ConfigRuntimeRefreshError";
156+
}
157+
}
158+
143159
function hashConfigRaw(raw: string | null): string {
144160
return crypto
145161
.createHash("sha256")
@@ -1306,6 +1322,7 @@ let configCache: {
13061322
} | null = null;
13071323
let runtimeConfigSnapshot: OpenClawConfig | null = null;
13081324
let runtimeConfigSourceSnapshot: OpenClawConfig | null = null;
1325+
let runtimeConfigSnapshotRefreshHandler: RuntimeConfigSnapshotRefreshHandler | null = null;
13091326

13101327
function resolveConfigCacheMs(env: NodeJS.ProcessEnv): number {
13111328
const raw = env.OPENCLAW_CONFIG_CACHE_MS?.trim();
@@ -1356,6 +1373,12 @@ export function getRuntimeConfigSourceSnapshot(): OpenClawConfig | null {
13561373
return runtimeConfigSourceSnapshot;
13571374
}
13581375

1376+
export function setRuntimeConfigSnapshotRefreshHandler(
1377+
refreshHandler: RuntimeConfigSnapshotRefreshHandler | null,
1378+
): void {
1379+
runtimeConfigSnapshotRefreshHandler = refreshHandler;
1380+
}
1381+
13591382
export function loadConfig(): OpenClawConfig {
13601383
if (runtimeConfigSnapshot) {
13611384
return runtimeConfigSnapshot;
@@ -1402,14 +1425,50 @@ export async function writeConfigFile(
14021425
): Promise<void> {
14031426
const io = createConfigIO();
14041427
let nextCfg = cfg;
1405-
if (runtimeConfigSnapshot && runtimeConfigSourceSnapshot) {
1406-
const runtimePatch = createMergePatch(runtimeConfigSnapshot, cfg);
1407-
nextCfg = coerceConfig(applyMergePatch(runtimeConfigSourceSnapshot, runtimePatch));
1428+
const hadRuntimeSnapshot = Boolean(runtimeConfigSnapshot);
1429+
const hadBothSnapshots = Boolean(runtimeConfigSnapshot && runtimeConfigSourceSnapshot);
1430+
if (hadBothSnapshots) {
1431+
const runtimePatch = createMergePatch(runtimeConfigSnapshot!, cfg);
1432+
nextCfg = coerceConfig(applyMergePatch(runtimeConfigSourceSnapshot!, runtimePatch));
14081433
}
14091434
const sameConfigPath =
14101435
options.expectedConfigPath === undefined || options.expectedConfigPath === io.configPath;
14111436
await io.writeConfigFile(nextCfg, {
14121437
envSnapshotForRestore: sameConfigPath ? options.envSnapshotForRestore : undefined,
14131438
unsetPaths: options.unsetPaths,
14141439
});
1440+
// Keep the last-known-good runtime snapshot active until the specialized refresh path
1441+
// succeeds, so concurrent readers do not observe unresolved SecretRefs mid-refresh.
1442+
const refreshHandler = runtimeConfigSnapshotRefreshHandler;
1443+
if (refreshHandler) {
1444+
try {
1445+
const refreshed = await refreshHandler.refresh({ sourceConfig: nextCfg });
1446+
if (refreshed) {
1447+
return;
1448+
}
1449+
} catch (error) {
1450+
try {
1451+
refreshHandler.clearOnRefreshFailure?.();
1452+
} catch {
1453+
// Keep the original refresh failure as the surfaced error.
1454+
}
1455+
const detail = error instanceof Error ? error.message : String(error);
1456+
throw new ConfigRuntimeRefreshError(
1457+
`Config was written to ${io.configPath}, but runtime snapshot refresh failed: ${detail}`,
1458+
{ cause: error },
1459+
);
1460+
}
1461+
}
1462+
if (hadBothSnapshots) {
1463+
// Refresh both snapshots from disk atomically so follow-up reads get normalized config and
1464+
// subsequent writes still get secret-preservation merge-patch (hadBothSnapshots stays true).
1465+
const fresh = io.loadConfig();
1466+
setRuntimeConfigSnapshot(fresh, nextCfg);
1467+
return;
1468+
}
1469+
if (hadRuntimeSnapshot) {
1470+
clearRuntimeConfigSnapshot();
1471+
}
1472+
// When we had no runtime snapshot, keep callers reading from disk/cache so external/manual
1473+
// edits to openclaw.json remain visible (no stale snapshot).
14151474
}

0 commit comments

Comments
 (0)