mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-27 00:52:05 +00:00
Merged via squash.
Prepared head SHA: 69e1861abf
Co-authored-by: bbblending <122739024+bbblending@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
export {
|
||||
clearConfigCache,
|
||||
ConfigRuntimeRefreshError,
|
||||
clearRuntimeConfigSnapshot,
|
||||
createConfigIO,
|
||||
getRuntimeConfigSnapshot,
|
||||
@@ -10,6 +11,7 @@ export {
|
||||
readConfigFileSnapshot,
|
||||
readConfigFileSnapshotForWrite,
|
||||
resolveConfigSnapshotHash,
|
||||
setRuntimeConfigSnapshotRefreshHandler,
|
||||
setRuntimeConfigSnapshot,
|
||||
writeConfigFile,
|
||||
} from "./io.js";
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -96,4 +98,117 @@ describe("runtime config snapshot writes", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("refreshes the runtime snapshot after writes so follow-up reads see persisted changes", async () => {
|
||||
await withTempHome("openclaw-config-runtime-write-refresh-", async (home) => {
|
||||
const configPath = path.join(home, ".openclaw", "openclaw.json");
|
||||
const sourceConfig: OpenClawConfig = {
|
||||
models: {
|
||||
providers: {
|
||||
openai: {
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const runtimeConfig: OpenClawConfig = {
|
||||
models: {
|
||||
providers: {
|
||||
openai: {
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
apiKey: "sk-runtime-resolved", // pragma: allowlist secret
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
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");
|
||||
|
||||
try {
|
||||
setRuntimeConfigSnapshot(runtimeConfig, sourceConfig);
|
||||
expect(loadConfig().gateway?.auth).toBeUndefined();
|
||||
|
||||
await writeConfigFile(nextRuntimeConfig);
|
||||
|
||||
expect(loadConfig().gateway?.auth).toEqual({ mode: "token" });
|
||||
expect(loadConfig().models?.providers?.openai?.apiKey).toBeDefined();
|
||||
|
||||
let persisted = JSON.parse(await fs.readFile(configPath, "utf8")) as {
|
||||
gateway?: { auth?: unknown };
|
||||
models?: { providers?: { openai?: { apiKey?: unknown } } };
|
||||
};
|
||||
expect(persisted.gateway?.auth).toEqual({ mode: "token" });
|
||||
// Post-write secret-ref: apiKey must stay as source ref (not plaintext).
|
||||
expect(persisted.models?.providers?.openai?.apiKey).toEqual({
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "OPENAI_API_KEY",
|
||||
});
|
||||
|
||||
// Follow-up write: runtimeConfigSourceSnapshot must be restored so second write
|
||||
// still runs secret-preservation merge-patch and keeps apiKey as ref (not plaintext).
|
||||
await writeConfigFile(loadConfig());
|
||||
persisted = JSON.parse(await fs.readFile(configPath, "utf8")) as {
|
||||
gateway?: { auth?: unknown };
|
||||
models?: { providers?: { openai?: { apiKey?: unknown } } };
|
||||
};
|
||||
expect(persisted.models?.providers?.openai?.apiKey).toEqual({
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "OPENAI_API_KEY",
|
||||
});
|
||||
} finally {
|
||||
clearRuntimeConfigSnapshot();
|
||||
clearConfigCache();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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<boolean>((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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -140,6 +140,22 @@ export type ReadConfigFileSnapshotForWriteResult = {
|
||||
writeOptions: ConfigWriteOptions;
|
||||
};
|
||||
|
||||
export type RuntimeConfigSnapshotRefreshParams = {
|
||||
sourceConfig: OpenClawConfig;
|
||||
};
|
||||
|
||||
export type RuntimeConfigSnapshotRefreshHandler = {
|
||||
refresh: (params: RuntimeConfigSnapshotRefreshParams) => boolean | Promise<boolean>;
|
||||
clearOnRefreshFailure?: () => void;
|
||||
};
|
||||
|
||||
export class ConfigRuntimeRefreshError extends Error {
|
||||
constructor(message: string, options?: { cause?: unknown }) {
|
||||
super(message, options);
|
||||
this.name = "ConfigRuntimeRefreshError";
|
||||
}
|
||||
}
|
||||
|
||||
function hashConfigRaw(raw: string | null): string {
|
||||
return crypto
|
||||
.createHash("sha256")
|
||||
@@ -1306,6 +1322,7 @@ let configCache: {
|
||||
} | null = null;
|
||||
let runtimeConfigSnapshot: OpenClawConfig | null = null;
|
||||
let runtimeConfigSourceSnapshot: OpenClawConfig | null = null;
|
||||
let runtimeConfigSnapshotRefreshHandler: RuntimeConfigSnapshotRefreshHandler | null = null;
|
||||
|
||||
function resolveConfigCacheMs(env: NodeJS.ProcessEnv): number {
|
||||
const raw = env.OPENCLAW_CONFIG_CACHE_MS?.trim();
|
||||
@@ -1356,6 +1373,12 @@ export function getRuntimeConfigSourceSnapshot(): OpenClawConfig | null {
|
||||
return runtimeConfigSourceSnapshot;
|
||||
}
|
||||
|
||||
export function setRuntimeConfigSnapshotRefreshHandler(
|
||||
refreshHandler: RuntimeConfigSnapshotRefreshHandler | null,
|
||||
): void {
|
||||
runtimeConfigSnapshotRefreshHandler = refreshHandler;
|
||||
}
|
||||
|
||||
export function loadConfig(): OpenClawConfig {
|
||||
if (runtimeConfigSnapshot) {
|
||||
return runtimeConfigSnapshot;
|
||||
@@ -1402,9 +1425,11 @@ export async function writeConfigFile(
|
||||
): Promise<void> {
|
||||
const io = createConfigIO();
|
||||
let nextCfg = cfg;
|
||||
if (runtimeConfigSnapshot && runtimeConfigSourceSnapshot) {
|
||||
const runtimePatch = createMergePatch(runtimeConfigSnapshot, cfg);
|
||||
nextCfg = coerceConfig(applyMergePatch(runtimeConfigSourceSnapshot, runtimePatch));
|
||||
const hadRuntimeSnapshot = Boolean(runtimeConfigSnapshot);
|
||||
const hadBothSnapshots = Boolean(runtimeConfigSnapshot && runtimeConfigSourceSnapshot);
|
||||
if (hadBothSnapshots) {
|
||||
const runtimePatch = createMergePatch(runtimeConfigSnapshot!, cfg);
|
||||
nextCfg = coerceConfig(applyMergePatch(runtimeConfigSourceSnapshot!, runtimePatch));
|
||||
}
|
||||
const sameConfigPath =
|
||||
options.expectedConfigPath === undefined || options.expectedConfigPath === io.configPath;
|
||||
@@ -1412,4 +1437,38 @@ export async function writeConfigFile(
|
||||
envSnapshotForRestore: sameConfigPath ? options.envSnapshotForRestore : undefined,
|
||||
unsetPaths: options.unsetPaths,
|
||||
});
|
||||
// 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 {
|
||||
const refreshed = await refreshHandler.refresh({ sourceConfig: nextCfg });
|
||||
if (refreshed) {
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
try {
|
||||
refreshHandler.clearOnRefreshFailure?.();
|
||||
} catch {
|
||||
// Keep the original refresh failure as the surfaced error.
|
||||
}
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
throw new ConfigRuntimeRefreshError(
|
||||
`Config was written to ${io.configPath}, but runtime snapshot refresh failed: ${detail}`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
}
|
||||
if (hadBothSnapshots) {
|
||||
// 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 = io.loadConfig();
|
||||
setRuntimeConfigSnapshot(fresh, nextCfg);
|
||||
return;
|
||||
}
|
||||
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).
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user