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 <karol.zalewski@4zal.net>

* 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 <steipete@gmail.com>
Co-authored-by: Karol Zalewski <karol.zalewski@4zal.net>
This commit is contained in:
VACInc
2026-07-15 06:56:12 -04:00
committed by GitHub
parent 2ac1b09838
commit 092bd16b79
8 changed files with 339 additions and 58 deletions

View File

@@ -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>): 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<OpenClawConfig>,
),
),
).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<OpenClawConfig>,
},
])("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();
});
});

View File

@@ -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 {

View File

@@ -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 &&

View File

@@ -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 &&

View File

@@ -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

View File

@@ -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,
});
}

View File

@@ -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);
});
});

View File

@@ -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,
});
}