fix: keep config writes independent of auth profile refs

This commit is contained in:
Peter Steinberger
2026-05-18 13:34:30 +01:00
parent 125ebd0987
commit 2bb448908d
10 changed files with 136 additions and 7 deletions

View File

@@ -41,6 +41,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Gateway/config: keep config writes from failing on unrelated unresolved auth-profile SecretRefs while preserving live auth-profile runtime snapshots.
- Discord/OpenAI: keep realtime Discord voice sessions hearing follow-up turns with OpenAI realtime and prebuffer assistant playback to avoid choppy starts. (#80505) Thanks @Solvely-Colin.
- Discord/subagents: route the initial reply from thread-bound delegated sessions into the bound Discord thread instead of the parent channel. Fixes #83170. (#83172) Thanks @100menotu001.
- Media: prevent image metadata probing from invoking external decoder delegates on unrecognized image bytes, and stop fallback chaining after real processing errors.

View File

@@ -100,6 +100,7 @@ import {
getRuntimeConfigSnapshotRefreshHandler as getRuntimeConfigSnapshotRefreshHandlerState,
setRuntimeConfigSnapshotRefreshHandler as setRuntimeConfigSnapshotRefreshHandlerState,
type ConfigWriteAfterWrite,
type RuntimeConfigSnapshotRefreshOptions,
type RuntimeConfigWriteNotification,
} from "./runtime-snapshot.js";
import { resolveShellEnvExpectedKeys } from "./shell-env-expected-keys.js";
@@ -219,6 +220,10 @@ export type ConfigWriteOptions = {
* the post-write runtime snapshot refresh/reload tail entirely.
*/
skipRuntimeSnapshotRefresh?: boolean;
/**
* Optional controls for the active runtime snapshot refresh after this write.
*/
runtimeRefresh?: RuntimeConfigSnapshotRefreshOptions;
/**
* Allow intentionally destructive config writes, such as explicit reset flows.
* Normal writers must keep this false so clobbers are rejected before disk commit.
@@ -2546,6 +2551,7 @@ export async function writeConfigFile(
// succeeds, so concurrent readers do not observe unresolved SecretRefs mid-refresh.
await finalizeRuntimeSnapshotWrite({
nextSourceConfig: canonicalSourceConfig,
refreshOptions: options.runtimeRefresh,
hadRuntimeSnapshot,
hadBothSnapshots,
loadFreshConfig: () => io.loadConfig(),

View File

@@ -332,6 +332,7 @@ async function tryWriteSingleTopLevelIncludeMutation(params: {
};
await finalizeRuntimeSnapshotWrite({
nextSourceConfig: refreshedSnapshot.sourceConfig,
refreshOptions: params.writeOptions?.runtimeRefresh,
hadRuntimeSnapshot,
hadBothSnapshots,
loadFreshConfig: () => refreshedSnapshot.runtimeConfig,

View File

@@ -1,7 +1,11 @@
import { createHash } from "node:crypto";
import type { OpenClawConfig } from "./types.js";
export type RuntimeConfigSnapshotRefreshParams = {
export type RuntimeConfigSnapshotRefreshOptions = {
includeAuthStoreRefs?: boolean;
};
export type RuntimeConfigSnapshotRefreshParams = RuntimeConfigSnapshotRefreshOptions & {
sourceConfig: OpenClawConfig;
};
@@ -265,6 +269,7 @@ export function loadPinnedRuntimeConfig(loadFresh: () => OpenClawConfig): OpenCl
export async function finalizeRuntimeSnapshotWrite(params: {
nextSourceConfig: OpenClawConfig;
refreshOptions?: RuntimeConfigSnapshotRefreshOptions;
hadRuntimeSnapshot: boolean;
hadBothSnapshots: boolean;
loadFreshConfig: () => OpenClawConfig;
@@ -275,7 +280,10 @@ export async function finalizeRuntimeSnapshotWrite(params: {
const refreshHandler = getRuntimeConfigSnapshotRefreshHandler();
if (refreshHandler) {
try {
const refreshed = await refreshHandler.refresh({ sourceConfig: params.nextSourceConfig });
const refreshed = await refreshHandler.refresh({
sourceConfig: params.nextSourceConfig,
...params.refreshOptions,
});
if (refreshed) {
params.notifyCommittedWrite();
return;

View File

@@ -215,7 +215,13 @@ export async function commitGatewayConfigWrite(params: {
}): Promise<{ path: string; config: OpenClawConfig; queueFollowUp: () => void }> {
const result = await replaceConfigFile({
nextConfig: params.nextConfig,
writeOptions: params.writeOptions,
writeOptions: {
...params.writeOptions,
runtimeRefresh: {
...params.writeOptions.runtimeRefresh,
includeAuthStoreRefs: false,
},
},
afterWrite: { mode: "auto" },
});
return {

View File

@@ -20,7 +20,12 @@ import {
GATEWAY_AUTH_SURFACE_PATHS,
evaluateGatewayAuthSurfaceStates,
} from "../secrets/runtime-gateway-auth-surfaces.js";
import { activateSecretsRuntimeSnapshotState } from "../secrets/runtime-state.js";
import {
activateSecretsRuntimeSnapshotState,
getActiveSecretsRuntimeSnapshot,
getLiveSecretsRuntimeAuthStores,
setPreparedSecretsRuntimeSnapshotRefreshContext,
} from "../secrets/runtime-state.js";
import { resolveGatewayAuth } from "./auth.js";
import { assertGatewayAuthNotKnownWeak } from "./known-weak-gateway-secrets.js";
import {
@@ -249,17 +254,30 @@ export function createRuntimeSecretsActivator(params: {
snapshot,
refreshContext: fastPath.refreshContext,
refreshHandler: {
refresh: async ({ sourceConfig }) => {
refresh: async ({ sourceConfig, includeAuthStoreRefs }) => {
const secretsRuntime = await loadSecretsRuntime();
const activeSnapshot = getActiveSecretsRuntimeSnapshot();
const oneShotSkipAuthStoreRefs =
includeAuthStoreRefs === false &&
fastPath.refreshContext.includeAuthStoreRefs;
const refreshed = await secretsRuntime.prepareSecretsRuntimeSnapshot({
config: sourceConfig,
env: fastPath.refreshContext.env,
agentDirs: resolveRefreshAgentDirs(sourceConfig, fastPath.refreshContext),
includeAuthStoreRefs:
includeAuthStoreRefs ?? fastPath.refreshContext.includeAuthStoreRefs,
loadablePluginOrigins: fastPath.refreshContext.loadablePluginOrigins,
...(fastPath.usesAuthStoreFallback || !fastPath.refreshContext.loadAuthStore
? {}
: { loadAuthStore: fastPath.refreshContext.loadAuthStore }),
});
if (oneShotSkipAuthStoreRefs && activeSnapshot) {
refreshed.authStores = getLiveSecretsRuntimeAuthStores();
setPreparedSecretsRuntimeSnapshotRefreshContext(
refreshed,
fastPath.refreshContext,
);
}
secretsRuntime.activateSecretsRuntimeSnapshot(refreshed);
return true;
},

View File

@@ -303,6 +303,7 @@ export function prepareSecretsRuntimeFastPathSnapshot(params: {
refreshContext: {
env: runtimeEnv,
explicitAgentDirs: params.agentDirs?.length ? [...candidateDirs] : null,
includeAuthStoreRefs,
loadablePluginOrigins: params.loadablePluginOrigins ?? new Map<string, PluginOrigin>(),
...(params.loadAuthStore ? { loadAuthStore: params.loadAuthStore } : {}),
},

View File

@@ -1,4 +1,7 @@
import { describe, expect, it } from "vitest";
import { setRuntimeAuthProfileStoreSnapshot } from "../agents/auth-profiles/runtime-snapshots.js";
import { getRuntimeConfigSnapshotRefreshHandler } from "../config/runtime-snapshot.js";
import { activateSecretsRuntimeSnapshot, getActiveSecretsRuntimeSnapshot } from "./runtime.js";
import {
asConfig,
loadAuthStoreWithProfiles,
@@ -41,6 +44,65 @@ describe("secrets runtime snapshot request secret refs", () => {
expect(snapshot.authStores).toStrictEqual([]);
});
it("can skip auth-profile SecretRef resolution during active runtime refresh", async () => {
const initialEnvVar = `OPENCLAW_INITIAL_AUTH_PROFILE_SECRET_${Date.now()}`;
const missingEnvVar = `OPENCLAW_MISSING_AUTH_PROFILE_SECRET_${Date.now()}`;
delete process.env[missingEnvVar];
let useMissingProfileRef = false;
let loadAuthStoreCalls = 0;
const loadAuthStore = () => {
loadAuthStoreCalls += 1;
return loadAuthStoreWithProfiles({
"custom:token": {
type: "token",
provider: "custom",
tokenRef: {
source: "env",
provider: "default",
id: useMissingProfileRef ? missingEnvVar : initialEnvVar,
},
},
});
};
const snapshot = await prepareSecretsRuntimeSnapshot({
config: asConfig({}),
env: { [initialEnvVar]: "sk-initial" },
agentDirs: ["/tmp/openclaw-agent-main"],
loadAuthStore,
});
activateSecretsRuntimeSnapshot(snapshot);
expect(loadAuthStoreCalls).toBe(1);
setRuntimeAuthProfileStoreSnapshot(
loadAuthStoreWithProfiles({
"custom:token": {
type: "token",
provider: "custom",
token: "sk-live",
},
}),
"/tmp/openclaw-agent-main",
);
useMissingProfileRef = true;
const refreshHandler = getRuntimeConfigSnapshotRefreshHandler();
if (!refreshHandler) {
throw new Error("Expected active runtime refresh handler");
}
await expect(
refreshHandler.refresh({
sourceConfig: asConfig({ gateway: { port: 19001 } }),
includeAuthStoreRefs: false,
}),
).resolves.toBe(true);
expect(loadAuthStoreCalls).toBe(1);
const profile = getActiveSecretsRuntimeSnapshot()?.authStores[0]?.store.profiles[
"custom:token"
] as { token?: string } | undefined;
expect(profile?.token).toBe("sk-live");
});
it("resolves model provider request secret refs for headers, auth, and tls material", async () => {
const config = asConfig({
models: {

View File

@@ -1,5 +1,6 @@
import {
clearRuntimeAuthProfileStoreSnapshots,
getRuntimeAuthProfileStoreSnapshot,
replaceRuntimeAuthProfileStoreSnapshots,
} from "../agents/auth-profiles/runtime-snapshots.js";
import { clearLoadedAuthStoreCache } from "../agents/auth-profiles/store-cache.js";
@@ -30,6 +31,7 @@ export type PreparedSecretsRuntimeSnapshot = {
export type SecretsRuntimeRefreshContext = {
env: Record<string, string | undefined>;
explicitAgentDirs: string[] | null;
includeAuthStoreRefs: boolean;
loadAuthStore?: (agentDir?: string) => AuthProfileStore;
loadablePluginOrigins: ReadonlyMap<string, PluginOrigin>;
};
@@ -48,6 +50,7 @@ export function cloneSecretsRuntimeRefreshContext(
const cloned: SecretsRuntimeRefreshContext = {
env: { ...context.env },
explicitAgentDirs: context.explicitAgentDirs ? [...context.explicitAgentDirs] : null,
includeAuthStoreRefs: context.includeAuthStoreRefs,
loadablePluginOrigins: new Map(context.loadablePluginOrigins),
};
if (context.loadAuthStore) {
@@ -131,6 +134,16 @@ export function getActiveSecretsRuntimeSnapshot(): PreparedSecretsRuntimeSnapsho
return snapshot;
}
export function getLiveSecretsRuntimeAuthStores(): PreparedSecretsRuntimeSnapshot["authStores"] {
if (!activeSnapshot) {
return [];
}
return activeSnapshot.authStores.map((entry) => ({
agentDir: entry.agentDir,
store: getRuntimeAuthProfileStoreSnapshot(entry.agentDir) ?? structuredClone(entry.store),
}));
}
export function clearSecretsRuntimeSnapshot(): void {
activeSnapshot = null;
activeRefreshContext = null;

View File

@@ -21,6 +21,7 @@ import {
getActiveSecretsRuntimeEnv as getActiveSecretsRuntimeEnvState,
getActiveSecretsRuntimeRefreshContext,
getActiveSecretsRuntimeSnapshot as getActiveSecretsRuntimeSnapshotState,
getLiveSecretsRuntimeAuthStores,
getPreparedSecretsRuntimeSnapshotRefreshContext,
registerSecretsRuntimeStateClearHook,
setPreparedSecretsRuntimeSnapshotRefreshContext,
@@ -123,6 +124,7 @@ export async function prepareSecretsRuntimeSnapshot(params: {
setPreparedSecretsRuntimeSnapshotRefreshContext(snapshot, {
env: runtimeEnv,
explicitAgentDirs: params.agentDirs?.length ? [...candidateDirs] : null,
includeAuthStoreRefs,
loadAuthStore: fastPathLoadAuthStore,
loadablePluginOrigins: params.loadablePluginOrigins ?? new Map<string, PluginOrigin>(),
});
@@ -197,6 +199,7 @@ export async function prepareSecretsRuntimeSnapshot(params: {
setPreparedSecretsRuntimeSnapshotRefreshContext(snapshot, {
env: runtimeEnv,
explicitAgentDirs: params.agentDirs?.length ? [...candidateDirs] : null,
includeAuthStoreRefs,
loadAuthStore: params.loadAuthStore ?? loadAuthProfileStoreForSecretsRuntime,
loadablePluginOrigins,
});
@@ -210,6 +213,7 @@ export function activateSecretsRuntimeSnapshot(snapshot: PreparedSecretsRuntimeS
({
env: { ...process.env } as Record<string, string | undefined>,
explicitAgentDirs: null,
includeAuthStoreRefs: snapshot.authStores.length > 0,
loadAuthStore: loadAuthProfileStoreForSecretsRuntime,
loadablePluginOrigins: new Map<string, PluginOrigin>(),
} satisfies SecretsRuntimeRefreshContext);
@@ -217,20 +221,28 @@ export function activateSecretsRuntimeSnapshot(snapshot: PreparedSecretsRuntimeS
snapshot,
refreshContext,
refreshHandler: {
refresh: async ({ sourceConfig }) => {
refresh: async ({ sourceConfig, includeAuthStoreRefs }) => {
const activeRefreshContext = getActiveSecretsRuntimeRefreshContext();
if (!getActiveSecretsRuntimeSnapshotState() || !activeRefreshContext) {
const activeSnapshot = getActiveSecretsRuntimeSnapshotState();
if (!activeSnapshot || !activeRefreshContext) {
return false;
}
const oneShotSkipAuthStoreRefs =
includeAuthStoreRefs === false && activeRefreshContext.includeAuthStoreRefs;
const refreshed = await prepareSecretsRuntimeSnapshot({
config: sourceConfig,
env: activeRefreshContext.env,
agentDirs: resolveRefreshAgentDirs(sourceConfig, activeRefreshContext),
includeAuthStoreRefs: includeAuthStoreRefs ?? activeRefreshContext.includeAuthStoreRefs,
loadablePluginOrigins: activeRefreshContext.loadablePluginOrigins,
...(activeRefreshContext.loadAuthStore
? { loadAuthStore: activeRefreshContext.loadAuthStore }
: {}),
});
if (oneShotSkipAuthStoreRefs) {
refreshed.authStores = getLiveSecretsRuntimeAuthStores();
setPreparedSecretsRuntimeSnapshotRefreshContext(refreshed, activeRefreshContext);
}
activateSecretsRuntimeSnapshot(refreshed);
return true;
},
@@ -248,6 +260,7 @@ export async function refreshActiveSecretsRuntimeSnapshot(): Promise<boolean> {
config: activeSnapshot.sourceConfig,
env: activeRefreshContext.env,
agentDirs: resolveRefreshAgentDirs(activeSnapshot.sourceConfig, activeRefreshContext),
includeAuthStoreRefs: activeRefreshContext.includeAuthStoreRefs,
loadablePluginOrigins: activeRefreshContext.loadablePluginOrigins,
...(activeRefreshContext.loadAuthStore
? { loadAuthStore: activeRefreshContext.loadAuthStore }