From 972d8fc1cf3ce5c5dfd0e68a6620c51565e7898f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 04:15:15 +0100 Subject: [PATCH] fix(agents): keep reply tool snapshots aligned --- CHANGELOG.md | 1 + src/agents/openclaw-plugin-tools.ts | 7 ++- ...w-tools.browser-plugin.integration.test.ts | 55 +++++++++++++++++++ ...ent-runner-utils.secret-resolution.test.ts | 45 ++++++++++++++- src/auto-reply/reply/agent-runner-utils.ts | 17 +++++- src/config/config.ts | 1 + src/config/io.ts | 2 + src/config/runtime-snapshot.test.ts | 49 +++++++++++++++++ src/config/runtime-snapshot.ts | 51 +++++++++++++++++ src/web/provider-runtime-shared.test.ts | 31 +++++++++++ src/web/provider-runtime-shared.ts | 3 +- 11 files changed, 256 insertions(+), 6 deletions(-) create mode 100644 src/web/provider-runtime-shared.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a66ab61595..a6dd8c48347 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,6 +69,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Config/recovery: skip whole-file last-known-good rollback when invalidity is scoped to `plugins.entries.*`, preserving unrelated user settings during plugin schema or host-version skew. Fixes #71289. Thanks @jalehman. +- Agents/tools: keep resolved reply-run configs from being overwritten by stale runtime snapshots, and let empty web runtime metadata fall back to configured provider auto-detection so standard and queued turns expose the same tool set. Fixes #71355. Thanks @c-g14. - Compaction: honor explicit `agents.defaults.compaction.keepRecentTokens` for manual `/compact`, re-distill safeguard summaries instead of snowballing previous summaries, and enable safeguard summary quality checks by default. Fixes #71357. Thanks @WhiteGiverMa. - Sessions: honor configured `session.maintenance` settings during load-time maintenance instead of falling back to default entry caps. Fixes #71356. Thanks @comolago. - Browser/sandbox: pass the resolved `browser.ssrfPolicy` into sandbox browser bridges and refresh cached bridges when the effective policy changes, so sandboxed browser navigation honors private-network opt-ins. Fixes #45153 and #57055. Thanks @jzakirov, @zuoanCo, and @kybrcore. diff --git a/src/agents/openclaw-plugin-tools.ts b/src/agents/openclaw-plugin-tools.ts index 2078356303e..43060640f02 100644 --- a/src/agents/openclaw-plugin-tools.ts +++ b/src/agents/openclaw-plugin-tools.ts @@ -1,3 +1,4 @@ +import { selectApplicableRuntimeConfig } from "../config/config.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { resolvePluginTools } from "../plugins/tools.js"; import { getActiveSecretsRuntimeSnapshot } from "../secrets/runtime.js"; @@ -45,7 +46,11 @@ export function resolveOpenClawPluginToolsForOptions(params: { ...resolveOpenClawPluginToolInputs({ options: params.options, resolvedConfig: params.resolvedConfig, - runtimeConfig: runtimeSnapshot?.config, + runtimeConfig: selectApplicableRuntimeConfig({ + inputConfig: params.resolvedConfig ?? params.options?.config, + runtimeConfig: runtimeSnapshot?.config, + runtimeSourceConfig: runtimeSnapshot?.sourceConfig, + }), }), existingToolNames: params.existingToolNames ?? new Set(), toolAllowlist: params.options?.pluginToolAllowlist, diff --git a/src/agents/openclaw-tools.browser-plugin.integration.test.ts b/src/agents/openclaw-tools.browser-plugin.integration.test.ts index 15e9f21b460..a1c986dc072 100644 --- a/src/agents/openclaw-tools.browser-plugin.integration.test.ts +++ b/src/agents/openclaw-tools.browser-plugin.integration.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; +import { activateSecretsRuntimeSnapshot, clearSecretsRuntimeSnapshot } from "../secrets/runtime.js"; import { resolveOpenClawPluginToolsForOptions } from "./openclaw-plugin-tools.js"; const hoisted = vi.hoisted(() => ({ @@ -13,6 +14,7 @@ vi.mock("../plugins/tools.js", () => ({ describe("createOpenClawTools browser plugin integration", () => { afterEach(() => { hoisted.resolvePluginTools.mockReset(); + clearSecretsRuntimeSnapshot(); }); it("keeps the browser tool returned by plugin resolution", () => { @@ -117,4 +119,57 @@ describe("createOpenClawTools browser plugin integration", () => { const details = (result.details ?? {}) as { workspaceOnly?: boolean | null }; expect(details.workspaceOnly).toBe(true); }); + + it("does not pass a stale active snapshot as plugin runtime config for a resolved run config", () => { + const staleSourceConfig = { + plugins: { + allow: ["old-plugin"], + }, + } as OpenClawConfig; + const staleRuntimeConfig = { + plugins: { + allow: ["old-plugin"], + }, + } as OpenClawConfig; + const resolvedRunConfig = { + plugins: { + allow: ["browser"], + }, + tools: { + experimental: { + planTool: true, + }, + }, + } as OpenClawConfig; + let capturedRuntimeConfig: OpenClawConfig | undefined; + hoisted.resolvePluginTools.mockImplementation((params: unknown) => { + capturedRuntimeConfig = (params as { context?: { runtimeConfig?: OpenClawConfig } }).context + ?.runtimeConfig; + return []; + }); + activateSecretsRuntimeSnapshot({ + sourceConfig: staleSourceConfig, + config: staleRuntimeConfig, + authStores: [], + warnings: [], + webTools: { + search: { + providerSource: "none", + diagnostics: [], + }, + fetch: { + providerSource: "none", + diagnostics: [], + }, + diagnostics: [], + }, + }); + + resolveOpenClawPluginToolsForOptions({ + options: { config: resolvedRunConfig }, + resolvedConfig: resolvedRunConfig, + }); + + expect(capturedRuntimeConfig).toBe(resolvedRunConfig); + }); }); diff --git a/src/auto-reply/reply/agent-runner-utils.secret-resolution.test.ts b/src/auto-reply/reply/agent-runner-utils.secret-resolution.test.ts index 86657fc62e9..cb075edb8d4 100644 --- a/src/auto-reply/reply/agent-runner-utils.secret-resolution.test.ts +++ b/src/auto-reply/reply/agent-runner-utils.secret-resolution.test.ts @@ -17,7 +17,8 @@ vi.mock("../../cli/command-secret-targets.js", () => ({ hoisted.getScopedChannelsCommandSecretTargetsMock(...args), })); -const { resolveQueuedReplyExecutionConfig } = await import("./agent-runner-utils.js"); +const { resolveQueuedReplyExecutionConfig, resolveQueuedReplyRuntimeConfig } = + await import("./agent-runner-utils.js"); const { clearRuntimeConfigSnapshot, setRuntimeConfigSnapshot } = await import("../../config/config.js"); @@ -145,4 +146,46 @@ describe("resolveQueuedReplyExecutionConfig channel scope", () => { accountId: undefined, }); }); + + it("does not replace an already resolved run config with a stale runtime snapshot", () => { + const sourceConfig = { + models: { + providers: { + openai: { + apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, + models: [], + }, + }, + }, + } as unknown as OpenClawConfig; + const staleRuntimeConfig = { + models: { + providers: { + openai: { + apiKey: "stale-runtime-key", + models: [], + }, + }, + }, + } as unknown as OpenClawConfig; + const scopedResolvedConfig = { + models: { + providers: { + openai: { + apiKey: "fresh-scoped-key", + models: [], + }, + }, + }, + tools: { + experimental: { + planTool: true, + }, + }, + } as unknown as OpenClawConfig; + setRuntimeConfigSnapshot(staleRuntimeConfig, sourceConfig); + + expect(resolveQueuedReplyRuntimeConfig(structuredClone(sourceConfig))).toBe(staleRuntimeConfig); + expect(resolveQueuedReplyRuntimeConfig(scopedResolvedConfig)).toBe(scopedResolvedConfig); + }); }); diff --git a/src/auto-reply/reply/agent-runner-utils.ts b/src/auto-reply/reply/agent-runner-utils.ts index e635926abd2..23b273353da 100644 --- a/src/auto-reply/reply/agent-runner-utils.ts +++ b/src/auto-reply/reply/agent-runner-utils.ts @@ -11,7 +11,12 @@ import { getScopedChannelsCommandSecretTargets, } from "../../cli/command-secret-targets.js"; import { resolveMessageSecretScope } from "../../cli/message-secret-scope.js"; -import { getRuntimeConfigSnapshot, type OpenClawConfig } from "../../config/config.js"; +import { + getRuntimeConfigSnapshot, + getRuntimeConfigSourceSnapshot, + selectApplicableRuntimeConfig, + type OpenClawConfig, +} from "../../config/config.js"; import { normalizeOptionalLowercaseString, normalizeOptionalString, @@ -29,8 +34,16 @@ import type { FollowupRun } from "./queue.js"; const BUN_FETCH_SOCKET_ERROR_RE = /socket connection was closed unexpectedly/i; export function resolveQueuedReplyRuntimeConfig(config: OpenClawConfig): OpenClawConfig { + const runtimeConfig = + typeof getRuntimeConfigSnapshot === "function" ? getRuntimeConfigSnapshot() : null; + const runtimeSourceConfig = + typeof getRuntimeConfigSourceSnapshot === "function" ? getRuntimeConfigSourceSnapshot() : null; return ( - (typeof getRuntimeConfigSnapshot === "function" ? getRuntimeConfigSnapshot() : null) ?? config + selectApplicableRuntimeConfig({ + inputConfig: config, + runtimeConfig, + runtimeSourceConfig, + }) ?? config ); } diff --git a/src/config/config.ts b/src/config/config.ts index 79bc92817e2..56c3a28a8d0 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -21,6 +21,7 @@ export { recoverConfigFromJsonRootSuffix, resetConfigRuntimeState, resolveConfigSnapshotHash, + selectApplicableRuntimeConfig, setRuntimeConfigSnapshotRefreshHandler, setRuntimeConfigSnapshot, writeConfigFile, diff --git a/src/config/io.ts b/src/config/io.ts index 45f4e8f6318..df539ffafcd 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -80,6 +80,7 @@ import { notifyRuntimeConfigWriteListeners, registerRuntimeConfigWriteListener, resetConfigRuntimeState as resetConfigRuntimeStateState, + selectApplicableRuntimeConfig, setRuntimeConfigSnapshot as setRuntimeConfigSnapshotState, getRuntimeConfigSnapshotRefreshHandler as getRuntimeConfigSnapshotRefreshHandlerState, setRuntimeConfigSnapshotRefreshHandler as setRuntimeConfigSnapshotRefreshHandlerState, @@ -98,6 +99,7 @@ export { getRuntimeConfigSnapshotState as getRuntimeConfigSnapshot, getRuntimeConfigSourceSnapshotState as getRuntimeConfigSourceSnapshot, resetConfigRuntimeStateState as resetConfigRuntimeState, + selectApplicableRuntimeConfig, setRuntimeConfigSnapshotState as setRuntimeConfigSnapshot, setRuntimeConfigSnapshotRefreshHandlerState as setRuntimeConfigSnapshotRefreshHandler, }; diff --git a/src/config/runtime-snapshot.test.ts b/src/config/runtime-snapshot.test.ts index 6c967a57c5d..e82ff09ec21 100644 --- a/src/config/runtime-snapshot.test.ts +++ b/src/config/runtime-snapshot.test.ts @@ -7,6 +7,7 @@ import { notifyRuntimeConfigWriteListeners, registerRuntimeConfigWriteListener, resetConfigRuntimeState, + selectApplicableRuntimeConfig, setRuntimeConfigSnapshot, setRuntimeConfigSnapshotRefreshHandler, } from "./runtime-snapshot.js"; @@ -70,6 +71,54 @@ describe("runtime snapshot state", () => { expect(getRuntimeConfigSourceSnapshot()).toEqual(sourceConfig); }); + it("selects runtime config only when input still matches the runtime source", () => { + 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", + models: [], + }, + }, + }, + }; + const scopedResolvedConfig: OpenClawConfig = { + ...runtimeConfig, + tools: { + experimental: { + planTool: true, + }, + }, + }; + + expect( + selectApplicableRuntimeConfig({ + inputConfig: structuredClone(sourceConfig), + runtimeConfig, + runtimeSourceConfig: sourceConfig, + }), + ).toBe(runtimeConfig); + expect( + selectApplicableRuntimeConfig({ + inputConfig: scopedResolvedConfig, + runtimeConfig, + runtimeSourceConfig: sourceConfig, + }), + ).toBe(scopedResolvedConfig); + }); + it("clears runtime source snapshot when runtime snapshot is cleared", () => { setRuntimeConfigSnapshot({ gateway: { port: 18789 } }, { gateway: { port: 18789 } }); resetRuntimeConfigState(); diff --git a/src/config/runtime-snapshot.ts b/src/config/runtime-snapshot.ts index c741b88834e..6ae2275f92d 100644 --- a/src/config/runtime-snapshot.ts +++ b/src/config/runtime-snapshot.ts @@ -22,6 +22,31 @@ let runtimeConfigSourceSnapshot: OpenClawConfig | null = null; let runtimeConfigSnapshotRefreshHandler: RuntimeConfigSnapshotRefreshHandler | null = null; const runtimeConfigWriteListeners = new Set<(event: RuntimeConfigWriteNotification) => void>(); +function stableConfigStringify(value: unknown): string { + if (value === null || typeof value !== "object") { + return JSON.stringify(value) ?? "null"; + } + if (Array.isArray(value)) { + return `[${value.map((entry) => stableConfigStringify(entry)).join(",")}]`; + } + const record = value as Record; + const keys = Object.keys(record).toSorted(); + return `{${keys + .map((key) => `${JSON.stringify(key)}:${stableConfigStringify(record[key])}`) + .join(",")}}`; +} + +function configSnapshotsMatch(left: OpenClawConfig, right: OpenClawConfig): boolean { + if (left === right) { + return true; + } + try { + return stableConfigStringify(left) === stableConfigStringify(right); + } catch { + return false; + } +} + export function setRuntimeConfigSnapshot( config: OpenClawConfig, sourceConfig?: OpenClawConfig, @@ -47,6 +72,32 @@ export function getRuntimeConfigSourceSnapshot(): OpenClawConfig | null { return runtimeConfigSourceSnapshot; } +export function selectApplicableRuntimeConfig(params: { + inputConfig?: OpenClawConfig; + runtimeConfig?: OpenClawConfig | null; + runtimeSourceConfig?: OpenClawConfig | null; +}): OpenClawConfig | undefined { + const runtimeConfig = params.runtimeConfig ?? null; + if (!runtimeConfig) { + return params.inputConfig; + } + const inputConfig = params.inputConfig; + if (!inputConfig) { + return runtimeConfig; + } + if (inputConfig === runtimeConfig) { + return inputConfig; + } + const runtimeSourceConfig = params.runtimeSourceConfig ?? null; + if (!runtimeSourceConfig) { + return runtimeConfig; + } + if (configSnapshotsMatch(inputConfig, runtimeSourceConfig)) { + return runtimeConfig; + } + return inputConfig; +} + export function setRuntimeConfigSnapshotRefreshHandler( refreshHandler: RuntimeConfigSnapshotRefreshHandler | null, ): void { diff --git a/src/web/provider-runtime-shared.test.ts b/src/web/provider-runtime-shared.test.ts new file mode 100644 index 00000000000..968d46ecca9 --- /dev/null +++ b/src/web/provider-runtime-shared.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { resolveWebProviderDefinition } from "./provider-runtime-shared.js"; + +describe("resolveWebProviderDefinition", () => { + it("falls back to auto-detect when runtime metadata has no selected provider", () => { + const resolved = resolveWebProviderDefinition({ + config: {}, + toolConfig: { enabled: true }, + runtimeMetadata: {}, + providers: [ + { + id: "custom", + }, + ], + resolveEnabled: () => true, + resolveAutoProviderId: () => "custom", + createTool: ({ provider }) => ({ + name: provider.id, + }), + }); + + expect(resolved).toEqual({ + provider: { + id: "custom", + }, + definition: { + name: "custom", + }, + }); + }); +}); diff --git a/src/web/provider-runtime-shared.ts b/src/web/provider-runtime-shared.ts index e5c9b034427..d43db5c5aae 100644 --- a/src/web/provider-runtime-shared.ts +++ b/src/web/provider-runtime-shared.ts @@ -133,8 +133,7 @@ export function resolveWebProviderDefinition< providers, }); const providerId = - params.providerId ?? - (params.runtimeMetadata ? params.runtimeMetadata.selectedProvider : autoProviderId); + params.providerId ?? params.runtimeMetadata?.selectedProvider ?? autoProviderId; if (!providerId) { return null; }