diff --git a/CHANGELOG.md b/CHANGELOG.md index 518aea4874a..aaa265fa028 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/config/io.ts b/src/config/io.ts index bbda3d9a441..15cdd5ad9a0 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -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(), diff --git a/src/config/mutate.ts b/src/config/mutate.ts index c97bd3adbe3..d3751b5b92a 100644 --- a/src/config/mutate.ts +++ b/src/config/mutate.ts @@ -332,6 +332,7 @@ async function tryWriteSingleTopLevelIncludeMutation(params: { }; await finalizeRuntimeSnapshotWrite({ nextSourceConfig: refreshedSnapshot.sourceConfig, + refreshOptions: params.writeOptions?.runtimeRefresh, hadRuntimeSnapshot, hadBothSnapshots, loadFreshConfig: () => refreshedSnapshot.runtimeConfig, diff --git a/src/config/runtime-snapshot.ts b/src/config/runtime-snapshot.ts index 1ca99f97bc6..436f05389bb 100644 --- a/src/config/runtime-snapshot.ts +++ b/src/config/runtime-snapshot.ts @@ -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; diff --git a/src/gateway/server-methods/config-write-flow.ts b/src/gateway/server-methods/config-write-flow.ts index 1e519196158..c8d28a8abb9 100644 --- a/src/gateway/server-methods/config-write-flow.ts +++ b/src/gateway/server-methods/config-write-flow.ts @@ -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 { diff --git a/src/gateway/server-startup-config.ts b/src/gateway/server-startup-config.ts index 86d186bfbd4..d60ac9a8633 100644 --- a/src/gateway/server-startup-config.ts +++ b/src/gateway/server-startup-config.ts @@ -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; }, diff --git a/src/secrets/runtime-fast-path.ts b/src/secrets/runtime-fast-path.ts index f7ab727848d..bb57b8e1363 100644 --- a/src/secrets/runtime-fast-path.ts +++ b/src/secrets/runtime-fast-path.ts @@ -303,6 +303,7 @@ export function prepareSecretsRuntimeFastPathSnapshot(params: { refreshContext: { env: runtimeEnv, explicitAgentDirs: params.agentDirs?.length ? [...candidateDirs] : null, + includeAuthStoreRefs, loadablePluginOrigins: params.loadablePluginOrigins ?? new Map(), ...(params.loadAuthStore ? { loadAuthStore: params.loadAuthStore } : {}), }, diff --git a/src/secrets/runtime-request-secret-refs.test.ts b/src/secrets/runtime-request-secret-refs.test.ts index 1db96bedeea..276f11ed660 100644 --- a/src/secrets/runtime-request-secret-refs.test.ts +++ b/src/secrets/runtime-request-secret-refs.test.ts @@ -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: { diff --git a/src/secrets/runtime-state.ts b/src/secrets/runtime-state.ts index b3b2251253a..f97f7516e6f 100644 --- a/src/secrets/runtime-state.ts +++ b/src/secrets/runtime-state.ts @@ -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; explicitAgentDirs: string[] | null; + includeAuthStoreRefs: boolean; loadAuthStore?: (agentDir?: string) => AuthProfileStore; loadablePluginOrigins: ReadonlyMap; }; @@ -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; diff --git a/src/secrets/runtime.ts b/src/secrets/runtime.ts index c86cf5e2564..9d135ad53c9 100644 --- a/src/secrets/runtime.ts +++ b/src/secrets/runtime.ts @@ -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(), }); @@ -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, explicitAgentDirs: null, + includeAuthStoreRefs: snapshot.authStores.length > 0, loadAuthStore: loadAuthProfileStoreForSecretsRuntime, loadablePluginOrigins: new Map(), } 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 { config: activeSnapshot.sourceConfig, env: activeRefreshContext.env, agentDirs: resolveRefreshAgentDirs(activeSnapshot.sourceConfig, activeRefreshContext), + includeAuthStoreRefs: activeRefreshContext.includeAuthStoreRefs, loadablePluginOrigins: activeRefreshContext.loadablePluginOrigins, ...(activeRefreshContext.loadAuthStore ? { loadAuthStore: activeRefreshContext.loadAuthStore }