mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 11:51:35 +00:00
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:
115
extensions/tavily/src/config.test.ts
Normal file
115
extensions/tavily/src/config.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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
|
||||
|
||||
29
src/agents/tool-runtime-config.ts
Normal file
29
src/agents/tool-runtime-config.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
100
src/agents/tool-search-runtime-config.test.ts
Normal file
100
src/agents/tool-search-runtime-config.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
23
src/agents/tool-search-runtime-config.ts
Normal file
23
src/agents/tool-search-runtime-config.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user