From 092bd16b79225ff2125e8cd99cc721fb1bfa2308 Mon Sep 17 00:00:00 2001 From: VACInc Date: Wed, 15 Jul 2026 06:56:12 -0400 Subject: [PATCH] fix: resolve Tavily SecretRefs in agent tools (#97827) * fix(tools): preserve resolved search credentials Co-authored-by: VACInc <3279061+VACInc@users.noreply.github.com> Co-authored-by: Karol Zalewski * test(agents): isolate Tool Search runtime config coverage * test(agents): isolate Tool Search runtime config coverage * test(agents): isolate Tool Search runtime config coverage * test(agents): isolate Tool Search runtime config coverage * fix(tools): restore reviewed search credential delta * docs(changelog): defer release-owned entry --------- Co-authored-by: Peter Steinberger Co-authored-by: Karol Zalewski --- extensions/tavily/src/config.test.ts | 115 ++++++++++++++++++ extensions/tavily/src/config.ts | 71 ++++++++--- .../run/attempt-tool-base-prepare.ts | 15 ++- src/agents/harness/tool-surface-bridge.ts | 15 ++- src/agents/openclaw-plugin-tools.ts | 29 +---- src/agents/tool-runtime-config.ts | 29 +++++ src/agents/tool-search-runtime-config.test.ts | 100 +++++++++++++++ src/agents/tool-search-runtime-config.ts | 23 ++++ 8 files changed, 339 insertions(+), 58 deletions(-) create mode 100644 extensions/tavily/src/config.test.ts create mode 100644 src/agents/tool-runtime-config.ts create mode 100644 src/agents/tool-search-runtime-config.test.ts create mode 100644 src/agents/tool-search-runtime-config.ts diff --git a/extensions/tavily/src/config.test.ts b/extensions/tavily/src/config.test.ts new file mode 100644 index 000000000000..a0c02a4e9fd3 --- /dev/null +++ b/extensions/tavily/src/config.test.ts @@ -0,0 +1,115 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { resolveTavilyApiKey } from "./config.js"; + +function configWithApiKey(apiKey: unknown, extra?: Partial): OpenClawConfig { + return { + ...extra, + plugins: { + entries: { + tavily: { + config: { + webSearch: { + apiKey, + }, + }, + }, + }, + }, + } as OpenClawConfig; +} + +describe("resolveTavilyApiKey", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("falls back to process.env.TAVILY_API_KEY for a matching unresolved env SecretRef", () => { + vi.stubEnv("TAVILY_API_KEY", "dummy"); + + expect( + resolveTavilyApiKey( + configWithApiKey({ + source: "env", + provider: "default", + id: "TAVILY_API_KEY", + }), + ), + ).toBe("dummy"); + }); + + it("allows a configured env provider when its allowlist includes TAVILY_API_KEY", () => { + vi.stubEnv("TAVILY_API_KEY", "dummy"); + + expect( + resolveTavilyApiKey( + configWithApiKey( + { + source: "env", + provider: "managed-env", + id: "TAVILY_API_KEY", + }, + { + secrets: { + providers: { + "managed-env": { + source: "env", + allowlist: ["TAVILY_API_KEY"], + }, + }, + }, + } as Partial, + ), + ), + ).toBe("dummy"); + }); + + it.each([ + { + name: "file SecretRef", + apiKey: { + source: "file", + provider: "default", + id: "/etc/secrets/tavily", + }, + }, + { + name: "exec SecretRef", + apiKey: { + source: "exec", + provider: "default", + id: "TAVILY_API_KEY", + }, + }, + { + name: "different env id", + apiKey: { + source: "env", + provider: "default", + id: "OTHER_API_KEY", + }, + }, + { + name: "env provider with a blocking allowlist", + apiKey: { + source: "env", + provider: "managed-env", + id: "TAVILY_API_KEY", + }, + extra: { + secrets: { + providers: { + "managed-env": { + source: "env", + allowlist: [], + }, + }, + }, + } as Partial, + }, + ])("does not fall back to process.env.TAVILY_API_KEY for $name", ({ apiKey, extra }) => { + vi.stubEnv("TAVILY_API_KEY", "dummy"); + + expect(resolveTavilyApiKey(configWithApiKey(apiKey, extra))).toBeUndefined(); + }); +}); diff --git a/extensions/tavily/src/config.ts b/extensions/tavily/src/config.ts index 6fc86fd09243..28703bb3be33 100644 --- a/extensions/tavily/src/config.ts +++ b/extensions/tavily/src/config.ts @@ -1,15 +1,14 @@ // Tavily helper module supports config behavior. import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { canResolveEnvSecretRefInReadOnlyPath } from "openclaw/plugin-sdk/extension-shared"; import { resolvePositiveTimeoutSeconds } from "openclaw/plugin-sdk/provider-web-search"; -import { - normalizeResolvedSecretInputString, - normalizeSecretInput, -} from "openclaw/plugin-sdk/secret-input"; +import { resolveSecretInputString, normalizeSecretInput } from "openclaw/plugin-sdk/secret-input"; import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; export const DEFAULT_TAVILY_BASE_URL = "https://api.tavily.com"; const DEFAULT_TAVILY_SEARCH_TIMEOUT_SECONDS = 30; const DEFAULT_TAVILY_EXTRACT_TIMEOUT_SECONDS = 60; +const TAVILY_API_KEY_ENV_VAR = "TAVILY_API_KEY"; type TavilySearchConfig = | { @@ -34,22 +33,64 @@ function resolveTavilySearchConfig(cfg?: OpenClawConfig): TavilySearchConfig { return undefined; } -function normalizeConfiguredSecret(value: unknown, path: string): string | undefined { - return normalizeSecretInput( - normalizeResolvedSecretInputString({ - value, - path, - }), - ); +type ConfiguredSecretResolution = + | { status: "available"; value: string } + | { status: "missing" } + | { status: "blocked" }; + +function resolveConfiguredSecret( + value: unknown, + path: string, + cfg?: OpenClawConfig, +): ConfiguredSecretResolution { + const resolved = resolveSecretInputString({ + value, + path, + defaults: cfg?.secrets?.defaults, + mode: "inspect", + }); + if (resolved.status === "available") { + const normalized = normalizeSecretInput(resolved.value); + return normalized ? { status: "available", value: normalized } : { status: "missing" }; + } + if (resolved.status === "missing") { + return { status: "missing" }; + } + // Explicit unavailable refs must not silently borrow an unrelated ambient credential. + if (resolved.ref.source !== "env") { + return { status: "blocked" }; + } + const envVarName = resolved.ref.id.trim(); + if (envVarName !== TAVILY_API_KEY_ENV_VAR) { + return { status: "blocked" }; + } + if ( + !canResolveEnvSecretRefInReadOnlyPath({ + cfg, + provider: resolved.ref.provider, + id: envVarName, + }) + ) { + return { status: "blocked" }; + } + const envValue = normalizeSecretInput(process.env[envVarName]); + return envValue ? { status: "available", value: envValue } : { status: "missing" }; } export function resolveTavilyApiKey(cfg?: OpenClawConfig): string | undefined { const search = resolveTavilySearchConfig(cfg); - return ( - normalizeConfiguredSecret(search?.apiKey, "plugins.entries.tavily.config.webSearch.apiKey") || - normalizeSecretInput(process.env.TAVILY_API_KEY) || - undefined + const resolved = resolveConfiguredSecret( + search?.apiKey, + "plugins.entries.tavily.config.webSearch.apiKey", + cfg, ); + if (resolved.status === "available") { + return resolved.value; + } + if (resolved.status === "blocked") { + return undefined; + } + return normalizeSecretInput(process.env.TAVILY_API_KEY) || undefined; } export function resolveTavilyBaseUrl(cfg?: OpenClawConfig): string { diff --git a/src/agents/embedded-agent-runner/run/attempt-tool-base-prepare.ts b/src/agents/embedded-agent-runner/run/attempt-tool-base-prepare.ts index f3557f0a86aa..9919c334eb47 100644 --- a/src/agents/embedded-agent-runner/run/attempt-tool-base-prepare.ts +++ b/src/agents/embedded-agent-runner/run/attempt-tool-base-prepare.ts @@ -8,7 +8,6 @@ import { getChannelAgentToolMeta } from "../../channel-tools.js"; import { resolveCodeModeConfig } from "../../code-mode.js"; import { resolveConversationCapabilityProfile } from "../../conversation-capability-profile.js"; import { - applyLocalModelLeanToolSearchDefaults, isLocalModelLeanEnabled, resolveLocalModelLeanPreserveToolNames, } from "../../local-model-lean.js"; @@ -16,6 +15,7 @@ import { resolveModelAuthMode } from "../../model-auth.js"; import { supportsModelTools } from "../../model-tool-support.js"; import type { SandboxContext } from "../../sandbox/types.js"; import { isAgentToolRestartSafe } from "../../tool-replay-safety.js"; +import { resolveAgentToolSearchRuntimeConfig } from "../../tool-search-runtime-config.js"; import { createToolSearchCatalogRef, resolveToolSearchConfig, @@ -75,13 +75,12 @@ export function prepareEmbeddedAttemptToolBase(params: { toolsAllow: toolsAllowWithForcedRuntimeTools, }); const codeModeConfig = resolveCodeModeConfig(attempt.config, params.sessionAgentId); - const toolSearchRuntimeConfig = forceDirectMessageTool - ? attempt.config - : applyLocalModelLeanToolSearchDefaults({ - config: attempt.config, - agentId: params.sessionAgentId, - sessionKey: params.sandboxSessionKey, - }); + const toolSearchRuntimeConfig = resolveAgentToolSearchRuntimeConfig({ + config: attempt.config, + agentId: params.sessionAgentId, + sessionKey: params.sandboxSessionKey, + forceDirectMessageTool, + }); const toolSearchConfig = resolveToolSearchConfig(toolSearchRuntimeConfig); const codeModeControlsEnabledForRun = toolsEnabled && diff --git a/src/agents/harness/tool-surface-bridge.ts b/src/agents/harness/tool-surface-bridge.ts index df61ead8b712..5146190ea2dd 100644 --- a/src/agents/harness/tool-surface-bridge.ts +++ b/src/agents/harness/tool-surface-bridge.ts @@ -10,13 +10,13 @@ import { } from "../code-mode.js"; import { resolveConversationCapabilityProfile } from "../conversation-capability-profile.js"; import { - applyLocalModelLeanToolSearchDefaults, filterLocalModelLeanTools, isLocalModelLeanEnabled, resolveLocalModelLeanPreserveToolNames, shouldCatalogToolForLocalModelLean, } from "../local-model-lean.js"; import { filterRuntimeCompatibleTools } from "../tool-schema-projection.js"; +import { resolveAgentToolSearchRuntimeConfig } from "../tool-search-runtime-config.js"; import { applyToolSchemaDirectoryCatalog, applyToolSearchCatalog, @@ -85,13 +85,12 @@ export function createAgentHarnessToolSurfaceRuntime(params: { sessionKey: params.sessionKey, }); const codeModeConfig = resolveCodeModeConfig(params.config, params.agentId); - const toolSearchRuntimeConfig = forceDirectMessageTool - ? params.config - : applyLocalModelLeanToolSearchDefaults({ - config: params.config, - agentId: params.agentId, - sessionKey: params.sessionKey, - }); + const toolSearchRuntimeConfig = resolveAgentToolSearchRuntimeConfig({ + config: params.config, + agentId: params.agentId, + sessionKey: params.sessionKey, + forceDirectMessageTool, + }); const toolSearchConfig = resolveToolSearchConfig(toolSearchRuntimeConfig); const toolsAvailable = params.modelToolsEnabled && diff --git a/src/agents/openclaw-plugin-tools.ts b/src/agents/openclaw-plugin-tools.ts index 44841a1ccd96..56d92eaf4728 100644 --- a/src/agents/openclaw-plugin-tools.ts +++ b/src/agents/openclaw-plugin-tools.ts @@ -4,11 +4,6 @@ * This module builds runtime plugin tools from config/options, delivery context, * auth profiles, and the current runtime config snapshot. */ -import { selectApplicableRuntimeConfig } from "../config/config.js"; -import { - getRuntimeConfigSnapshot, - getRuntimeConfigSourceSnapshot, -} from "../config/runtime-snapshot.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { resolvePluginTools } from "../plugins/tools.js"; import { normalizeDeliveryContext } from "../utils/delivery-context.js"; @@ -20,6 +15,7 @@ import { type OpenClawPluginToolOptions, } from "./openclaw-tools.plugin-context.js"; import { applyPluginToolDeliveryDefaults } from "./plugin-tool-delivery-defaults.js"; +import { resolveAgentRuntimeToolConfig } from "./tool-runtime-config.js"; import type { AnyAgentTool } from "./tools/common.js"; type ResolveOpenClawPluginToolsOptions = OpenClawPluginToolOptions & { @@ -41,27 +37,6 @@ type ResolveOpenClawPluginToolsOptions = OpenClawPluginToolOptions & { authProfileStore?: AuthProfileStore; }; -function resolveApplicablePluginRuntimeConfig( - inputConfig?: OpenClawConfig, -): OpenClawConfig | undefined { - const runtimeConfig = getRuntimeConfigSnapshot() ?? undefined; - if (!runtimeConfig) { - return inputConfig; - } - if (!inputConfig || inputConfig === runtimeConfig) { - return runtimeConfig; - } - const runtimeSourceConfig = getRuntimeConfigSourceSnapshot() ?? undefined; - if (!runtimeSourceConfig) { - return inputConfig; - } - return selectApplicableRuntimeConfig({ - inputConfig, - runtimeConfig, - runtimeSourceConfig, - }); -} - /** Resolves plugin tools for an agent run and applies delivery-context defaults. */ export function resolveOpenClawPluginToolsForOptions(params: { options?: ResolveOpenClawPluginToolsOptions; @@ -82,7 +57,7 @@ export function resolveOpenClawPluginToolsForOptions(params: { const resolveCurrentRuntimeConfig = () => { // Re-resolve on demand so auth/profile lookups see the active runtime config // while tests can still inject a fixed resolvedConfig. - return resolveApplicablePluginRuntimeConfig(params.resolvedConfig ?? params.options?.config); + return resolveAgentRuntimeToolConfig(params.resolvedConfig ?? params.options?.config); }; const authProfileStore = params.options?.authProfileStore; const resolveAuthProfileIdsForProvider = authProfileStore diff --git a/src/agents/tool-runtime-config.ts b/src/agents/tool-runtime-config.ts new file mode 100644 index 000000000000..42d7e1203e77 --- /dev/null +++ b/src/agents/tool-runtime-config.ts @@ -0,0 +1,29 @@ +// Selects the resolved runtime snapshot for agent tool surfaces. +import { + getRuntimeConfigSnapshot, + getRuntimeConfigSourceSnapshot, + selectApplicableRuntimeConfig, +} from "../config/config.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; + +export function resolveAgentRuntimeToolConfig( + inputConfig?: OpenClawConfig, +): OpenClawConfig | undefined { + const runtimeConfig = getRuntimeConfigSnapshot() ?? undefined; + if (!runtimeConfig) { + return inputConfig; + } + if (!inputConfig || inputConfig === runtimeConfig) { + return runtimeConfig; + } + const runtimeSourceConfig = getRuntimeConfigSourceSnapshot() ?? undefined; + // Without source identity, a process-global snapshot must not replace an explicit run config. + if (!runtimeSourceConfig) { + return inputConfig; + } + return selectApplicableRuntimeConfig({ + inputConfig, + runtimeConfig, + runtimeSourceConfig, + }); +} diff --git a/src/agents/tool-search-runtime-config.test.ts b/src/agents/tool-search-runtime-config.test.ts new file mode 100644 index 000000000000..a17a4d120086 --- /dev/null +++ b/src/agents/tool-search-runtime-config.test.ts @@ -0,0 +1,100 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { resetConfigRuntimeState, setRuntimeConfigSnapshot } from "../config/config.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { resolveAgentRuntimeToolConfig } from "./tool-runtime-config.js"; +import { resolveAgentToolSearchRuntimeConfig } from "./tool-search-runtime-config.js"; + +function createRuntimeConfigPair() { + const sourceConfig = { + agents: { defaults: { experimental: { localModelLean: true } } }, + plugins: { + entries: { + "example-plugin": { + config: { + marker: { + source: "exec", + provider: "example", + id: "example/value", + }, + }, + }, + }, + }, + } as OpenClawConfig; + const runtimeConfig = { + ...sourceConfig, + plugins: { + entries: { + "example-plugin": { + config: { marker: "resolved" }, + }, + }, + }, + } as OpenClawConfig; + return { runtimeConfig, sourceConfig }; +} + +describe("resolveAgentToolSearchRuntimeConfig", () => { + afterEach(() => { + resetConfigRuntimeState(); + }); + + it("applies Tool Search defaults after selecting the resolved runtime snapshot", () => { + const { runtimeConfig, sourceConfig } = createRuntimeConfigPair(); + setRuntimeConfigSnapshot(runtimeConfig, sourceConfig); + + const resolved = resolveAgentToolSearchRuntimeConfig({ config: sourceConfig }); + + expect(resolved?.tools?.toolSearch).toEqual({ + enabled: true, + mode: "tools", + searchDefaultLimit: 5, + maxSearchLimit: 10, + }); + expect(resolved?.plugins?.entries?.["example-plugin"]?.config).toMatchObject({ + marker: "resolved", + }); + expect(runtimeConfig.tools).toBeUndefined(); + expect(sourceConfig.plugins?.entries?.["example-plugin"]?.config).toMatchObject({ + marker: { + source: "exec", + provider: "example", + id: "example/value", + }, + }); + }); + + it("returns the resolved snapshot unchanged for direct-message-only tool surfaces", () => { + const { runtimeConfig, sourceConfig } = createRuntimeConfigPair(); + setRuntimeConfigSnapshot(runtimeConfig, sourceConfig); + + expect( + resolveAgentToolSearchRuntimeConfig({ + config: sourceConfig, + forceDirectMessageTool: true, + }), + ).toBe(runtimeConfig); + }); + + it("preserves an explicit config that is unrelated to the active source snapshot", () => { + const { runtimeConfig, sourceConfig } = createRuntimeConfigPair(); + setRuntimeConfigSnapshot(runtimeConfig, sourceConfig); + const explicitConfig = { + plugins: { + entries: { + "example-plugin": { config: { marker: "explicit" } }, + }, + }, + } as OpenClawConfig; + + expect(resolveAgentRuntimeToolConfig(explicitConfig)).toBe(explicitConfig); + expect(resolveAgentToolSearchRuntimeConfig({ config: explicitConfig })).toBe(explicitConfig); + }); + + it("uses the input config when no runtime snapshot exists", () => { + const config = { tools: { toolSearch: false } } as OpenClawConfig; + + expect(resolveAgentRuntimeToolConfig(config)).toBe(config); + expect(resolveAgentToolSearchRuntimeConfig({ config })).toBe(config); + }); +}); diff --git a/src/agents/tool-search-runtime-config.ts b/src/agents/tool-search-runtime-config.ts new file mode 100644 index 000000000000..6de4f79d56d4 --- /dev/null +++ b/src/agents/tool-search-runtime-config.ts @@ -0,0 +1,23 @@ +// Applies Tool Search overlays on top of the selected runtime config. +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { applyLocalModelLeanToolSearchDefaults } from "./local-model-lean.js"; +import { resolveAgentRuntimeToolConfig } from "./tool-runtime-config.js"; + +export function resolveAgentToolSearchRuntimeConfig(params: { + config?: OpenClawConfig; + agentId?: string; + sessionKey?: string; + forceDirectMessageTool?: boolean; +}): OpenClawConfig | undefined { + // Select before overlay cloning; cloning source config first loses snapshot identity and can + // reintroduce unresolved SecretRefs into plugin tool factories. + const runtimeConfig = resolveAgentRuntimeToolConfig(params.config); + if (params.forceDirectMessageTool) { + return runtimeConfig; + } + return applyLocalModelLeanToolSearchDefaults({ + config: runtimeConfig, + agentId: params.agentId, + sessionKey: params.sessionKey, + }); +}