Skip to content

Commit 62f06e8

Browse files
committed
fix(tavily): fall back to process.env when config SecretRef is unresolvable
Replaces the try/catch SecretRef resolution with an inspect-mode pattern matching extensions/firecrawl. When the configured apiKey SecretRef cannot be resolved at runtime (e.g. env-backed credential not inlined in the snapshot), the resolver now returns 'blocked' instead of throwing, letting the ambient process.env.TAVILY_API_KEY fallback work as intended. Adds comprehensive unit tests covering resolveConfiguredSecret inspect-mode behavior, env allowlist, whitespace edge cases, and end-to-end apiKey resolution.
1 parent 6495523 commit 62f06e8

2 files changed

Lines changed: 264 additions & 15 deletions

File tree

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
import { afterEach, describe, expect, it } from "vitest";
2+
import { resolveTavilyApiKey } from "./config.js";
3+
4+
describe("resolveTavilyApiKey", () => {
5+
const originalEnv = process.env.TAVILY_API_KEY;
6+
7+
afterEach(() => {
8+
if (originalEnv === undefined) {
9+
delete process.env.TAVILY_API_KEY;
10+
} else {
11+
process.env.TAVILY_API_KEY = originalEnv;
12+
}
13+
});
14+
15+
it("returns the configured string apiKey as-is", () => {
16+
expect(
17+
resolveTavilyApiKey({
18+
plugins: {
19+
entries: {
20+
tavily: {
21+
config: {
22+
webSearch: { apiKey: "configured-key" },
23+
},
24+
},
25+
},
26+
},
27+
} as never),
28+
).toBe("configured-key");
29+
});
30+
31+
it("falls back to process.env.TAVILY_API_KEY when the configured env SecretRef is unresolvable", () => {
32+
// Real SecretRef input — the bundled SDK v2026.6.8 already supports
33+
// mode: "inspect" (see types.secrets-* in dist), so the production
34+
// resolver path runs end-to-end without any vi.mock.
35+
process.env.TAVILY_API_KEY = "env-fallback-key";
36+
expect(
37+
resolveTavilyApiKey({
38+
plugins: {
39+
entries: {
40+
tavily: {
41+
config: {
42+
webSearch: {
43+
apiKey: {
44+
source: "env",
45+
provider: "default",
46+
id: "TAVILY_API_KEY",
47+
},
48+
},
49+
},
50+
},
51+
},
52+
},
53+
} as never),
54+
).toBe("env-fallback-key");
55+
});
56+
57+
it("does NOT fall back to process.env.TAVILY_API_KEY when the configured SecretRef is a file source", () => {
58+
// File-backed SecretRefs are blocked at the inspect-mode layer before
59+
// the env fallback is consulted. Even when process.env.TAVILY_API_KEY
60+
// is set, the file ref must not be silently replaced with the ambient
61+
// env value.
62+
process.env.TAVILY_API_KEY = "env-fallback-key";
63+
expect(
64+
resolveTavilyApiKey({
65+
plugins: {
66+
entries: {
67+
tavily: {
68+
config: {
69+
webSearch: {
70+
apiKey: {
71+
source: "file",
72+
provider: "default",
73+
id: "/etc/secrets/tavily",
74+
},
75+
},
76+
},
77+
},
78+
},
79+
},
80+
} as never),
81+
).toBeUndefined();
82+
});
83+
84+
it("does NOT fall back to process.env.TAVILY_API_KEY when the configured env SecretRef targets a different env var", () => {
85+
// Env-backed SecretRefs whose id is not TAVILY_API_KEY must not be
86+
// silently rewritten to the ambient TAVILY_API_KEY value.
87+
process.env.TAVILY_API_KEY = "env-fallback-key";
88+
expect(
89+
resolveTavilyApiKey({
90+
plugins: {
91+
entries: {
92+
tavily: {
93+
config: {
94+
webSearch: {
95+
apiKey: {
96+
source: "env",
97+
provider: "default",
98+
id: "OTHER_API_KEY",
99+
},
100+
},
101+
},
102+
},
103+
},
104+
},
105+
} as never),
106+
).toBeUndefined();
107+
});
108+
109+
it("does NOT fall back when the configured SecretRef source is exec (any id)", () => {
110+
// Symmetric with the file-source test: any non-env source is blocked
111+
// before the ambient env fallback is consulted.
112+
process.env.TAVILY_API_KEY = "env-fallback-key";
113+
expect(
114+
resolveTavilyApiKey({
115+
plugins: {
116+
entries: {
117+
tavily: {
118+
config: {
119+
webSearch: {
120+
apiKey: {
121+
source: "exec",
122+
provider: "default",
123+
id: "TAVILY_API_KEY",
124+
},
125+
},
126+
},
127+
},
128+
},
129+
},
130+
} as never),
131+
).toBeUndefined();
132+
});
133+
134+
it("trims whitespace from the configured env SecretRef id before matching", () => {
135+
// The helper applies .trim() in the resolver so YAML config that pads
136+
// env var names still matches TAVILY_API_KEY. This pins the behavior.
137+
process.env.TAVILY_API_KEY = "env-fallback-key";
138+
expect(
139+
resolveTavilyApiKey({
140+
plugins: {
141+
entries: {
142+
tavily: {
143+
config: {
144+
webSearch: {
145+
apiKey: {
146+
source: "env",
147+
provider: "default",
148+
id: " TAVILY_API_KEY ",
149+
},
150+
},
151+
},
152+
},
153+
},
154+
},
155+
} as never),
156+
).toBe("env-fallback-key");
157+
});
158+
159+
it("does NOT fall back when the configured env SecretRef uses a provider not in the env allowlist", () => {
160+
// canResolveEnvSecretRefInReadOnlyPath gates on cfg.secrets.providers
161+
// allowlist. A non-default provider without an allowlist entry must not
162+
// silently fall through to process.env.TAVILY_API_KEY.
163+
process.env.TAVILY_API_KEY = "env-fallback-key";
164+
expect(
165+
resolveTavilyApiKey({
166+
secrets: {
167+
providers: {
168+
"unknown-vault": { source: "env", allowlist: [] },
169+
},
170+
},
171+
plugins: {
172+
entries: {
173+
tavily: {
174+
config: {
175+
webSearch: {
176+
apiKey: {
177+
source: "env",
178+
provider: "unknown-vault",
179+
id: "TAVILY_API_KEY",
180+
},
181+
},
182+
},
183+
},
184+
},
185+
},
186+
} as never),
187+
).toBeUndefined();
188+
});
189+
190+
it("returns undefined when neither config nor env var is set", () => {
191+
delete process.env.TAVILY_API_KEY;
192+
expect(
193+
resolveTavilyApiKey({
194+
plugins: {
195+
entries: {
196+
tavily: { config: { webSearch: {} } },
197+
},
198+
},
199+
} as never),
200+
).toBeUndefined();
201+
});
202+
});

extensions/tavily/src/config.ts

Lines changed: 62 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,70 @@ 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+
// Resolves a configured apiKey SecretRef, matching the inspect-mode pattern
42+
// in `extensions/firecrawl/src/config.ts`. Returns `available` for a usable
43+
// credential, `missing` when the input was absent, and `blocked` when the
44+
// caller configured a SecretRef that this read-only path will not silently
45+
// fall back past (file/exec refs and env refs whose `id` is not
46+
// `TAVILY_API_KEY`). The caller uses `blocked` to decide whether it may
47+
// still consult `process.env.TAVILY_API_KEY` as ambient fallback.
48+
function resolveConfiguredSecret(
49+
value: unknown,
50+
path: string,
51+
cfg?: OpenClawConfig,
52+
): ConfiguredSecretResolution {
53+
const resolved = resolveSecretInputString({
54+
value,
55+
path,
56+
defaults: cfg?.secrets?.defaults,
57+
mode: "inspect",
58+
});
59+
if (resolved.status === "available") {
60+
const normalized = normalizeSecretInput(resolved.value);
61+
return normalized ? { status: "available", value: normalized } : { status: "missing" };
62+
}
63+
if (resolved.status === "missing") {
64+
return { status: "missing" };
65+
}
66+
if (resolved.ref.source !== "env") {
67+
return { status: "blocked" };
68+
}
69+
const envVarName = resolved.ref.id.trim();
70+
if (envVarName !== TAVILY_API_KEY_ENV_VAR) {
71+
return { status: "blocked" };
72+
}
73+
if (
74+
!canResolveEnvSecretRefInReadOnlyPath({
75+
cfg,
76+
provider: resolved.ref.provider,
77+
id: envVarName,
78+
})
79+
) {
80+
return { status: "blocked" };
81+
}
82+
const envValue = normalizeSecretInput(process.env[envVarName]);
83+
return envValue ? { status: "available", value: envValue } : { status: "missing" };
4484
}
4585

4686
export function resolveTavilyApiKey(cfg?: OpenClawConfig): string | undefined {
4787
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
88+
const resolved = resolveConfiguredSecret(
89+
search?.apiKey,
90+
"plugins.entries.tavily.config.webSearch.apiKey",
91+
cfg,
5292
);
93+
if (resolved.status === "available") {
94+
return resolved.value;
95+
}
96+
if (resolved.status === "blocked") {
97+
return undefined;
98+
}
99+
return normalizeSecretInput(process.env.TAVILY_API_KEY) || undefined;
53100
}
54101

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

0 commit comments

Comments
 (0)