Skip to content

Commit dd07d2b

Browse files
steipetekzzalews
authored andcommitted
fix(tools): preserve resolved search credentials
Co-authored-by: VACInc <[email protected]> Co-authored-by: Karol Zalewski <[email protected]>
1 parent e035601 commit dd07d2b

8 files changed

Lines changed: 303 additions & 59 deletions

File tree

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
2+
import { afterEach, describe, expect, it, vi } from "vitest";
3+
import { resolveTavilyApiKey } from "./config.js";
4+
5+
function configWithApiKey(apiKey: unknown, extra?: Partial<OpenClawConfig>): OpenClawConfig {
6+
return {
7+
...extra,
8+
plugins: {
9+
entries: {
10+
tavily: {
11+
config: {
12+
webSearch: {
13+
apiKey,
14+
},
15+
},
16+
},
17+
},
18+
},
19+
} as OpenClawConfig;
20+
}
21+
22+
describe("resolveTavilyApiKey", () => {
23+
afterEach(() => {
24+
vi.unstubAllEnvs();
25+
});
26+
27+
it("falls back to process.env.TAVILY_API_KEY for a matching unresolved env SecretRef", () => {
28+
vi.stubEnv("TAVILY_API_KEY", "dummy");
29+
30+
expect(
31+
resolveTavilyApiKey(
32+
configWithApiKey({
33+
source: "env",
34+
provider: "default",
35+
id: "TAVILY_API_KEY",
36+
}),
37+
),
38+
).toBe("dummy");
39+
});
40+
41+
it("allows a configured env provider when its allowlist includes TAVILY_API_KEY", () => {
42+
vi.stubEnv("TAVILY_API_KEY", "dummy");
43+
44+
expect(
45+
resolveTavilyApiKey(
46+
configWithApiKey(
47+
{
48+
source: "env",
49+
provider: "managed-env",
50+
id: "TAVILY_API_KEY",
51+
},
52+
{
53+
secrets: {
54+
providers: {
55+
"managed-env": {
56+
source: "env",
57+
allowlist: ["TAVILY_API_KEY"],
58+
},
59+
},
60+
},
61+
} as Partial<OpenClawConfig>,
62+
),
63+
),
64+
).toBe("dummy");
65+
});
66+
67+
it.each([
68+
{
69+
name: "file SecretRef",
70+
apiKey: {
71+
source: "file",
72+
provider: "default",
73+
id: "/etc/secrets/tavily",
74+
},
75+
},
76+
{
77+
name: "exec SecretRef",
78+
apiKey: {
79+
source: "exec",
80+
provider: "default",
81+
id: "TAVILY_API_KEY",
82+
},
83+
},
84+
{
85+
name: "different env id",
86+
apiKey: {
87+
source: "env",
88+
provider: "default",
89+
id: "OTHER_API_KEY",
90+
},
91+
},
92+
{
93+
name: "env provider with a blocking allowlist",
94+
apiKey: {
95+
source: "env",
96+
provider: "managed-env",
97+
id: "TAVILY_API_KEY",
98+
},
99+
extra: {
100+
secrets: {
101+
providers: {
102+
"managed-env": {
103+
source: "env",
104+
allowlist: [],
105+
},
106+
},
107+
},
108+
} as Partial<OpenClawConfig>,
109+
},
110+
])("does not fall back to process.env.TAVILY_API_KEY for $name", ({ apiKey, extra }) => {
111+
vi.stubEnv("TAVILY_API_KEY", "dummy");
112+
113+
expect(resolveTavilyApiKey(configWithApiKey(apiKey, extra))).toBeUndefined();
114+
});
115+
});

extensions/tavily/src/config.ts

Lines changed: 56 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,14 @@
11
// Tavily helper module supports config behavior.
22
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
3+
import { canResolveEnvSecretRefInReadOnlyPath } from "openclaw/plugin-sdk/extension-shared";
34
import { resolvePositiveTimeoutSeconds } from "openclaw/plugin-sdk/provider-web-search";
4-
import {
5-
normalizeResolvedSecretInputString,
6-
normalizeSecretInput,
7-
} from "openclaw/plugin-sdk/secret-input";
5+
import { resolveSecretInputString, normalizeSecretInput } from "openclaw/plugin-sdk/secret-input";
86
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
97

108
export const DEFAULT_TAVILY_BASE_URL = "https://api.tavily.com";
119
const DEFAULT_TAVILY_SEARCH_TIMEOUT_SECONDS = 30;
1210
const DEFAULT_TAVILY_EXTRACT_TIMEOUT_SECONDS = 60;
11+
const TAVILY_API_KEY_ENV_VAR = "TAVILY_API_KEY";
1312

1413
type TavilySearchConfig =
1514
| {
@@ -34,22 +33,64 @@ function resolveTavilySearchConfig(cfg?: OpenClawConfig): TavilySearchConfig {
3433
return undefined;
3534
}
3635

37-
function normalizeConfiguredSecret(value: unknown, path: string): string | undefined {
38-
return normalizeSecretInput(
39-
normalizeResolvedSecretInputString({
40-
value,
41-
path,
42-
}),
43-
);
36+
type ConfiguredSecretResolution =
37+
| { status: "available"; value: string }
38+
| { status: "missing" }
39+
| { status: "blocked" };
40+
41+
function resolveConfiguredSecret(
42+
value: unknown,
43+
path: string,
44+
cfg?: OpenClawConfig,
45+
): ConfiguredSecretResolution {
46+
const resolved = resolveSecretInputString({
47+
value,
48+
path,
49+
defaults: cfg?.secrets?.defaults,
50+
mode: "inspect",
51+
});
52+
if (resolved.status === "available") {
53+
const normalized = normalizeSecretInput(resolved.value);
54+
return normalized ? { status: "available", value: normalized } : { status: "missing" };
55+
}
56+
if (resolved.status === "missing") {
57+
return { status: "missing" };
58+
}
59+
// Explicit unavailable refs must not silently borrow an unrelated ambient credential.
60+
if (resolved.ref.source !== "env") {
61+
return { status: "blocked" };
62+
}
63+
const envVarName = resolved.ref.id.trim();
64+
if (envVarName !== TAVILY_API_KEY_ENV_VAR) {
65+
return { status: "blocked" };
66+
}
67+
if (
68+
!canResolveEnvSecretRefInReadOnlyPath({
69+
cfg,
70+
provider: resolved.ref.provider,
71+
id: envVarName,
72+
})
73+
) {
74+
return { status: "blocked" };
75+
}
76+
const envValue = normalizeSecretInput(process.env[envVarName]);
77+
return envValue ? { status: "available", value: envValue } : { status: "missing" };
4478
}
4579

4680
export function resolveTavilyApiKey(cfg?: OpenClawConfig): string | undefined {
4781
const search = resolveTavilySearchConfig(cfg);
48-
return (
49-
normalizeConfiguredSecret(search?.apiKey, "plugins.entries.tavily.config.webSearch.apiKey") ||
50-
normalizeSecretInput(process.env.TAVILY_API_KEY) ||
51-
undefined
82+
const resolved = resolveConfiguredSecret(
83+
search?.apiKey,
84+
"plugins.entries.tavily.config.webSearch.apiKey",
85+
cfg,
5286
);
87+
if (resolved.status === "available") {
88+
return resolved.value;
89+
}
90+
if (resolved.status === "blocked") {
91+
return undefined;
92+
}
93+
return normalizeSecretInput(process.env.TAVILY_API_KEY) || undefined;
5394
}
5495

5596
export function resolveTavilyBaseUrl(cfg?: OpenClawConfig): string {

src/agents/embedded-agent-runner/run/attempt-tool-base-prepare.ts

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,14 @@ import { getChannelAgentToolMeta } from "../../channel-tools.js";
88
import { resolveCodeModeConfig } from "../../code-mode.js";
99
import { resolveConversationCapabilityProfile } from "../../conversation-capability-profile.js";
1010
import {
11-
applyLocalModelLeanToolSearchDefaults,
1211
isLocalModelLeanEnabled,
1312
resolveLocalModelLeanPreserveToolNames,
1413
} from "../../local-model-lean.js";
1514
import { resolveModelAuthMode } from "../../model-auth.js";
1615
import { supportsModelTools } from "../../model-tool-support.js";
1716
import type { SandboxContext } from "../../sandbox/types.js";
1817
import { isAgentToolRestartSafe } from "../../tool-replay-safety.js";
18+
import { resolveAgentToolSearchRuntimeConfig } from "../../tool-search-runtime-config.js";
1919
import {
2020
createToolSearchCatalogRef,
2121
resolveToolSearchConfig,
@@ -75,13 +75,12 @@ export function prepareEmbeddedAttemptToolBase(params: {
7575
toolsAllow: toolsAllowWithForcedRuntimeTools,
7676
});
7777
const codeModeConfig = resolveCodeModeConfig(attempt.config, params.sessionAgentId);
78-
const toolSearchRuntimeConfig = forceDirectMessageTool
79-
? attempt.config
80-
: applyLocalModelLeanToolSearchDefaults({
81-
config: attempt.config,
82-
agentId: params.sessionAgentId,
83-
sessionKey: params.sandboxSessionKey,
84-
});
78+
const toolSearchRuntimeConfig = resolveAgentToolSearchRuntimeConfig({
79+
config: attempt.config,
80+
agentId: params.sessionAgentId,
81+
sessionKey: params.sandboxSessionKey,
82+
forceDirectMessageTool,
83+
});
8584
const toolSearchConfig = resolveToolSearchConfig(toolSearchRuntimeConfig);
8685
const codeModeControlsEnabledForRun =
8786
toolsEnabled &&

src/agents/harness/tool-surface-bridge.test.ts

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { describe, expect, it } from "vitest";
1+
import { afterEach, describe, expect, it } from "vitest";
2+
import { resetConfigRuntimeState, setRuntimeConfigSnapshot } from "../../config/config.js";
23
import type { OpenClawConfig } from "../../config/types.openclaw.js";
34
import { runWithAgentRingZeroTools } from "../agent-tools.ring-zero-context.js";
45
import { createStubTool } from "../test-helpers/agent-tool-stubs.js";
@@ -24,6 +25,10 @@ function createRuntime(config: OpenClawConfig) {
2425
}
2526

2627
describe("createAgentHarnessToolSurfaceRuntime", () => {
28+
afterEach(() => {
29+
resetConfigRuntimeState();
30+
});
31+
2732
it("suppresses catalog controls for a host-scoped ring-zero run", () => {
2833
const crestodian = {
2934
...createStubTool("crestodian"),
@@ -114,4 +119,62 @@ describe("createAgentHarnessToolSurfaceRuntime", () => {
114119
testing.setToolSearchCodeModeSupportedForTest(undefined);
115120
}
116121
});
122+
123+
it("applies Tool Search defaults on top of the resolved runtime snapshot", () => {
124+
const sourceConfig = {
125+
agents: {
126+
defaults: {
127+
experimental: {
128+
localModelLean: true,
129+
},
130+
},
131+
},
132+
plugins: {
133+
entries: {
134+
"example-plugin": {
135+
config: {
136+
apiKey: {
137+
source: "exec",
138+
provider: "vault",
139+
id: "example/api-key",
140+
},
141+
},
142+
},
143+
},
144+
},
145+
} as OpenClawConfig;
146+
const runtimeConfig = {
147+
...sourceConfig,
148+
plugins: {
149+
entries: {
150+
"example-plugin": {
151+
config: {
152+
apiKey: "fake",
153+
},
154+
},
155+
},
156+
},
157+
} as OpenClawConfig;
158+
setRuntimeConfigSnapshot(runtimeConfig, sourceConfig);
159+
160+
const runtime = createRuntime(sourceConfig);
161+
162+
expect(runtime.config?.tools?.toolSearch).toEqual({
163+
enabled: true,
164+
mode: "tools",
165+
searchDefaultLimit: 5,
166+
maxSearchLimit: 10,
167+
});
168+
expect(runtime.config?.plugins?.entries?.["example-plugin"]?.config).toMatchObject({
169+
apiKey: "fake",
170+
});
171+
expect(sourceConfig.plugins?.entries?.["example-plugin"]?.config).toMatchObject({
172+
apiKey: {
173+
source: "exec",
174+
provider: "vault",
175+
id: "example/api-key",
176+
},
177+
});
178+
runtime.cleanup();
179+
});
117180
});

src/agents/harness/tool-surface-bridge.ts

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,13 @@ import {
1010
} from "../code-mode.js";
1111
import { resolveConversationCapabilityProfile } from "../conversation-capability-profile.js";
1212
import {
13-
applyLocalModelLeanToolSearchDefaults,
1413
filterLocalModelLeanTools,
1514
isLocalModelLeanEnabled,
1615
resolveLocalModelLeanPreserveToolNames,
1716
shouldCatalogToolForLocalModelLean,
1817
} from "../local-model-lean.js";
1918
import { filterRuntimeCompatibleTools } from "../tool-schema-projection.js";
19+
import { resolveAgentToolSearchRuntimeConfig } from "../tool-search-runtime-config.js";
2020
import {
2121
applyToolSchemaDirectoryCatalog,
2222
applyToolSearchCatalog,
@@ -85,13 +85,12 @@ export function createAgentHarnessToolSurfaceRuntime(params: {
8585
sessionKey: params.sessionKey,
8686
});
8787
const codeModeConfig = resolveCodeModeConfig(params.config, params.agentId);
88-
const toolSearchRuntimeConfig = forceDirectMessageTool
89-
? params.config
90-
: applyLocalModelLeanToolSearchDefaults({
91-
config: params.config,
92-
agentId: params.agentId,
93-
sessionKey: params.sessionKey,
94-
});
88+
const toolSearchRuntimeConfig = resolveAgentToolSearchRuntimeConfig({
89+
config: params.config,
90+
agentId: params.agentId,
91+
sessionKey: params.sessionKey,
92+
forceDirectMessageTool,
93+
});
9594
const toolSearchConfig = resolveToolSearchConfig(toolSearchRuntimeConfig);
9695
const toolsAvailable =
9796
params.modelToolsEnabled &&

0 commit comments

Comments
 (0)