fix(secrets): resolve web tool SecretRefs atomically at runtime

This commit is contained in:
Josh Avant 2026-03-09 22:57:03 -05:00 committed by GitHub
parent 93c44e3dad
commit f0eb67923c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 2059 additions and 112 deletions

View File

@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Resolve web tool SecretRefs atomically at runtime. (#41599) Thanks @joshavant.
- Feishu/local image auto-convert: pass `mediaLocalRoots` through the `sendText` local-image shim so allowed local image paths upload as Feishu images again instead of falling back to raw path text. (#40623) Thanks @ayanesakura.
- macOS/LaunchAgent install: tighten LaunchAgent directory and plist permissions during install so launchd bootstrap does not fail when the target home path or generated plist inherited group/world-writable modes.
- Gateway/Control UI: keep dashboard auth tokens in session-scoped browser storage so same-tab refreshes preserve remote token auth without restoring long-lived localStorage token persistence, while scoping tokens to the selected gateway URL and fragment-only bootstrap flow. (#40892) thanks @velvet-shark.

View File

@ -38,7 +38,8 @@ Examples of inactive surfaces:
- Top-level channel credentials that no enabled account inherits.
- Disabled tool/feature surfaces.
- Web search provider-specific keys that are not selected by `tools.web.search.provider`.
In auto mode (provider unset), provider-specific keys are also active for provider auto-detection.
In auto mode (provider unset), keys are consulted by precedence for provider auto-detection until one resolves.
After selection, non-selected provider keys are treated as inactive until selected.
- `gateway.remote.token` / `gateway.remote.password` SecretRefs are active (when `gateway.remote.enabled` is not `false`) if one of these is true:
- `gateway.mode=remote`
- `gateway.remote.url` is configured

View File

@ -1489,10 +1489,16 @@ Set `cli.banner.taglineMode` in config:
### How do I enable web search and web fetch
`web_fetch` works without an API key. `web_search` requires a Brave Search API
key. **Recommended:** run `openclaw configure --section web` to store it in
`tools.web.search.apiKey`. Environment alternative: set `BRAVE_API_KEY` for the
Gateway process.
`web_fetch` works without an API key. `web_search` requires a key for your
selected provider (Brave, Gemini, Grok, Kimi, or Perplexity).
**Recommended:** run `openclaw configure --section web` and choose a provider.
Environment alternatives:
- Brave: `BRAVE_API_KEY`
- Gemini: `GEMINI_API_KEY`
- Grok: `XAI_API_KEY`
- Kimi: `KIMI_API_KEY` or `MOONSHOT_API_KEY`
- Perplexity: `PERPLEXITY_API_KEY` or `OPENROUTER_API_KEY`
```json5
{
@ -1500,6 +1506,7 @@ Gateway process.
web: {
search: {
enabled: true,
provider: "brave",
apiKey: "BRAVE_API_KEY_HERE",
maxResults: 5,
},

View File

@ -71,11 +71,14 @@ Optional legacy controls:
**Via config:** run `openclaw configure --section web`. It stores the key in
`~/.openclaw/openclaw.json` under `tools.web.search.perplexity.apiKey`.
That field also accepts SecretRef objects.
**Via environment:** set `PERPLEXITY_API_KEY` or `OPENROUTER_API_KEY`
in the Gateway process environment. For a gateway install, put it in
`~/.openclaw/.env` (or your service environment). See [Env vars](/help/faq#how-does-openclaw-load-environment-variables).
If `provider: "perplexity"` is configured and the Perplexity key SecretRef is unresolved with no env fallback, startup/reload fails fast.
## Tool parameters
These parameters apply to the native Perplexity Search API path.

View File

@ -80,10 +80,10 @@ See [Memory](/concepts/memory).
`web_search` uses API keys and may incur usage charges depending on your provider:
- **Brave Search API**: `BRAVE_API_KEY` or `tools.web.search.apiKey`
- **Gemini (Google Search)**: `GEMINI_API_KEY`
- **Grok (xAI)**: `XAI_API_KEY`
- **Kimi (Moonshot)**: `KIMI_API_KEY` or `MOONSHOT_API_KEY`
- **Perplexity Search API**: `PERPLEXITY_API_KEY`
- **Gemini (Google Search)**: `GEMINI_API_KEY` or `tools.web.search.gemini.apiKey`
- **Grok (xAI)**: `XAI_API_KEY` or `tools.web.search.grok.apiKey`
- **Kimi (Moonshot)**: `KIMI_API_KEY`, `MOONSHOT_API_KEY`, or `tools.web.search.kimi.apiKey`
- **Perplexity Search API**: `PERPLEXITY_API_KEY`, `OPENROUTER_API_KEY`, or `tools.web.search.perplexity.apiKey`
**Brave Search free credit:** Each Brave plan includes $5/month in renewing
free credit. The Search plan costs $5 per 1,000 requests, so the credit covers

View File

@ -31,6 +31,7 @@ Scope intent:
- `talk.providers.*.apiKey`
- `messages.tts.elevenlabs.apiKey`
- `messages.tts.openai.apiKey`
- `tools.web.fetch.firecrawl.apiKey`
- `tools.web.search.apiKey`
- `tools.web.search.gemini.apiKey`
- `tools.web.search.grok.apiKey`
@ -102,7 +103,8 @@ Notes:
- For SecretRef-managed model providers, generated `agents/*/agent/models.json` entries persist non-secret markers (not resolved secret values) for `apiKey`/header surfaces.
- For web search:
- In explicit provider mode (`tools.web.search.provider` set), only the selected provider key is active.
- In auto mode (`tools.web.search.provider` unset), `tools.web.search.apiKey` and provider-specific keys are active.
- In auto mode (`tools.web.search.provider` unset), only the first provider key that resolves by precedence is active.
- In auto mode, non-selected provider refs are treated as inactive until selected.
## Unsupported credentials

View File

@ -454,6 +454,13 @@
"secretShape": "secret_input",
"optIn": true
},
{
"id": "tools.web.fetch.firecrawl.apiKey",
"configFile": "openclaw.json",
"path": "tools.web.fetch.firecrawl.apiKey",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "tools.web.search.apiKey",
"configFile": "openclaw.json",

View File

@ -40,7 +40,8 @@ with JS-heavy sites or pages that block plain HTTP fetches.
Notes:
- `firecrawl.enabled` defaults to true when an API key is present.
- `firecrawl.enabled` defaults to `true` unless explicitly set to `false`.
- Firecrawl fallback attempts run only when an API key is available (`tools.web.fetch.firecrawl.apiKey` or `FIRECRAWL_API_KEY`).
- `maxAgeMs` controls how old cached results can be (ms). Default is 2 days.
## Stealth / bot circumvention

View File

@ -2,7 +2,7 @@
summary: "Web search + fetch tools (Brave, Gemini, Grok, Kimi, and Perplexity providers)"
read_when:
- You want to enable web_search or web_fetch
- You need Brave or Perplexity Search API key setup
- You need provider API key setup
- You want to use Gemini with Google Search grounding
title: "Web Tools"
---
@ -49,6 +49,12 @@ The table above is alphabetical. If no `provider` is explicitly set, runtime aut
If no keys are found, it falls back to Brave (you'll get a missing-key error prompting you to configure one).
Runtime SecretRef behavior:
- Web tool SecretRefs are resolved atomically at gateway startup/reload.
- In auto-detect mode, OpenClaw resolves only the selected provider key. Non-selected provider SecretRefs stay inactive until selected.
- If the selected provider SecretRef is unresolved and no provider env fallback exists, startup/reload fails fast.
## Setting up web search
Use `openclaw configure --section web` to set up your API key and choose a provider.
@ -77,9 +83,25 @@ See [Perplexity Search API Docs](https://docs.perplexity.ai/guides/search-quicks
### Where to store the key
**Via config:** run `openclaw configure --section web`. It stores the key under `tools.web.search.apiKey` or `tools.web.search.perplexity.apiKey`, depending on provider.
**Via config:** run `openclaw configure --section web`. It stores the key under the provider-specific config path:
**Via environment:** set `PERPLEXITY_API_KEY`, `OPENROUTER_API_KEY`, or `BRAVE_API_KEY` in the Gateway process environment. For a gateway install, put it in `~/.openclaw/.env` (or your service environment). See [Env vars](/help/faq#how-does-openclaw-load-environment-variables).
- Brave: `tools.web.search.apiKey`
- Gemini: `tools.web.search.gemini.apiKey`
- Grok: `tools.web.search.grok.apiKey`
- Kimi: `tools.web.search.kimi.apiKey`
- Perplexity: `tools.web.search.perplexity.apiKey`
All of these fields also support SecretRef objects.
**Via environment:** set provider env vars in the Gateway process environment:
- Brave: `BRAVE_API_KEY`
- Gemini: `GEMINI_API_KEY`
- Grok: `XAI_API_KEY`
- Kimi: `KIMI_API_KEY` or `MOONSHOT_API_KEY`
- Perplexity: `PERPLEXITY_API_KEY` or `OPENROUTER_API_KEY`
For a gateway install, put these in `~/.openclaw/.env` (or your service environment). See [Env vars](/help/faq#how-does-openclaw-load-environment-variables).
### Config examples
@ -216,6 +238,7 @@ Search the web using your configured provider.
- **Grok**: `XAI_API_KEY` or `tools.web.search.grok.apiKey`
- **Kimi**: `KIMI_API_KEY`, `MOONSHOT_API_KEY`, or `tools.web.search.kimi.apiKey`
- **Perplexity**: `PERPLEXITY_API_KEY`, `OPENROUTER_API_KEY`, or `tools.web.search.perplexity.apiKey`
- All provider key fields above support SecretRef objects.
### Config
@ -310,6 +333,7 @@ Fetch a URL and extract readable content.
- `tools.web.fetch.enabled` must not be `false` (default: enabled)
- Optional Firecrawl fallback: set `tools.web.fetch.firecrawl.apiKey` or `FIRECRAWL_API_KEY`.
- `tools.web.fetch.firecrawl.apiKey` supports SecretRef objects.
### web_fetch config
@ -351,6 +375,8 @@ Notes:
- `web_fetch` uses Readability (main-content extraction) first, then Firecrawl (if configured). If both fail, the tool returns an error.
- Firecrawl requests use bot-circumvention mode and cache results by default.
- Firecrawl SecretRefs are resolved only when Firecrawl is active (`tools.web.fetch.enabled !== false` and `tools.web.fetch.firecrawl.enabled !== false`).
- If Firecrawl is active and its SecretRef is unresolved with no `FIRECRAWL_API_KEY` fallback, startup/reload fails fast.
- `web_fetch` sends a Chrome-like User-Agent and `Accept-Language` by default; override `userAgent` if needed.
- `web_fetch` blocks private/internal hostnames and re-checks redirects (limit with `maxRedirects`).
- `maxChars` is clamped to `tools.web.fetch.maxCharsCap`.

View File

@ -1,5 +1,6 @@
import type { OpenClawConfig } from "../config/config.js";
import { resolvePluginTools } from "../plugins/tools.js";
import { getActiveRuntimeWebToolsMetadata } from "../secrets/runtime.js";
import type { GatewayMessageChannel } from "../utils/message-channel.js";
import { resolveSessionAgentId } from "./agent-scope.js";
import type { SandboxFsBridge } from "./sandbox/fs-bridge.js";
@ -72,6 +73,7 @@ export function createOpenClawTools(
} & SpawnedToolContext,
): AnyAgentTool[] {
const workspaceDir = resolveWorkspaceRoot(options?.workspaceDir);
const runtimeWebTools = getActiveRuntimeWebToolsMetadata();
const imageTool = options?.agentDir?.trim()
? createImageTool({
config: options?.config,
@ -100,10 +102,12 @@ export function createOpenClawTools(
const webSearchTool = createWebSearchTool({
config: options?.config,
sandboxed: options?.sandboxed,
runtimeWebSearch: runtimeWebTools?.search,
});
const webFetchTool = createWebFetchTool({
config: options?.config,
sandboxed: options?.sandboxed,
runtimeFirecrawl: runtimeWebTools?.fetch.firecrawl,
});
const messageTool = options?.disableMessageTool
? null

View File

@ -0,0 +1,135 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import {
activateSecretsRuntimeSnapshot,
clearSecretsRuntimeSnapshot,
prepareSecretsRuntimeSnapshot,
} from "../secrets/runtime.js";
import { withFetchPreconnect } from "../test-utils/fetch-mock.js";
import { createOpenClawTools } from "./openclaw-tools.js";
vi.mock("../plugins/tools.js", () => ({
resolvePluginTools: () => [],
}));
function asConfig(value: unknown): OpenClawConfig {
return value as OpenClawConfig;
}
function findTool(name: string, config: OpenClawConfig) {
const allTools = createOpenClawTools({ config, sandboxed: true });
const tool = allTools.find((candidate) => candidate.name === name);
expect(tool).toBeDefined();
if (!tool) {
throw new Error(`missing ${name} tool`);
}
return tool;
}
function makeHeaders(map: Record<string, string>): { get: (key: string) => string | null } {
return {
get: (key) => map[key.toLowerCase()] ?? null,
};
}
async function prepareAndActivate(params: { config: OpenClawConfig; env?: NodeJS.ProcessEnv }) {
const snapshot = await prepareSecretsRuntimeSnapshot({
config: params.config,
env: params.env,
agentDirs: ["/tmp/openclaw-agent-main"],
loadAuthStore: () => ({ version: 1, profiles: {} }),
});
activateSecretsRuntimeSnapshot(snapshot);
return snapshot;
}
describe("openclaw tools runtime web metadata wiring", () => {
const priorFetch = global.fetch;
afterEach(() => {
global.fetch = priorFetch;
clearSecretsRuntimeSnapshot();
});
it("uses runtime-selected provider when higher-precedence provider ref is unresolved", async () => {
const snapshot = await prepareAndActivate({
config: asConfig({
tools: {
web: {
search: {
apiKey: { source: "env", provider: "default", id: "MISSING_BRAVE_KEY_REF" },
gemini: {
apiKey: { source: "env", provider: "default", id: "GEMINI_WEB_KEY_REF" },
},
},
},
},
}),
env: {
GEMINI_WEB_KEY_REF: "gemini-runtime-key",
},
});
expect(snapshot.webTools.search.selectedProvider).toBe("gemini");
const mockFetch = vi.fn((_input?: unknown, _init?: unknown) =>
Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
candidates: [
{
content: { parts: [{ text: "runtime gemini ok" }] },
groundingMetadata: { groundingChunks: [] },
},
],
}),
} as Response),
);
global.fetch = withFetchPreconnect(mockFetch);
const webSearch = findTool("web_search", snapshot.config);
const result = await webSearch.execute("call-runtime-search", { query: "runtime search" });
expect(mockFetch).toHaveBeenCalled();
expect(String(mockFetch.mock.calls[0]?.[0])).toContain("generativelanguage.googleapis.com");
expect((result.details as { provider?: string }).provider).toBe("gemini");
});
it("skips Firecrawl key resolution when runtime marks Firecrawl inactive", async () => {
const snapshot = await prepareAndActivate({
config: asConfig({
tools: {
web: {
fetch: {
firecrawl: {
enabled: false,
apiKey: { source: "env", provider: "default", id: "MISSING_FIRECRAWL_KEY_REF" },
},
},
},
},
}),
});
const mockFetch = vi.fn((_input?: unknown, _init?: unknown) =>
Promise.resolve({
ok: true,
status: 200,
headers: makeHeaders({ "content-type": "text/html; charset=utf-8" }),
text: () =>
Promise.resolve(
"<html><body><article><h1>Runtime Off</h1><p>Use direct fetch.</p></article></body></html>",
),
} as Response),
);
global.fetch = withFetchPreconnect(mockFetch);
const webFetch = findTool("web_fetch", snapshot.config);
await webFetch.execute("call-runtime-fetch", { url: "https://example.com/runtime-off" });
expect(mockFetch).toHaveBeenCalled();
expect(mockFetch.mock.calls[0]?.[0]).toBe("https://example.com/runtime-off");
expect(String(mockFetch.mock.calls[0]?.[0])).not.toContain("api.firecrawl.dev");
});
});

View File

@ -84,6 +84,47 @@ describe("web_fetch Cloudflare Markdown for Agents", () => {
expect(details?.contentType).toBe("text/html");
});
it("bypasses Firecrawl when runtime metadata marks Firecrawl inactive", async () => {
const fetchSpy = vi
.fn()
.mockResolvedValue(
htmlResponse(
"<html><body><article><h1>Runtime Off</h1><p>Use direct fetch.</p></article></body></html>",
),
);
global.fetch = withFetchPreconnect(fetchSpy);
const tool = createWebFetchTool({
config: {
tools: {
web: {
fetch: {
firecrawl: {
enabled: true,
apiKey: {
source: "env",
provider: "default",
id: "MISSING_FIRECRAWL_KEY_REF",
},
},
},
},
},
},
sandboxed: false,
runtimeFirecrawl: {
active: false,
apiKeySource: "secretRef",
diagnostics: [],
},
});
await tool?.execute?.("call", { url: "https://example.com/runtime-firecrawl-off" });
expect(fetchSpy).toHaveBeenCalled();
expect(fetchSpy.mock.calls[0]?.[0]).toBe("https://example.com/runtime-firecrawl-off");
});
it("logs x-markdown-tokens when header is present", async () => {
const logSpy = vi.spyOn(logger, "logDebug").mockImplementation(() => {});
const fetchSpy = vi

View File

@ -1,7 +1,9 @@
import { Type } from "@sinclair/typebox";
import type { OpenClawConfig } from "../../config/config.js";
import { normalizeResolvedSecretInputString } from "../../config/types.secrets.js";
import { SsrFBlockedError } from "../../infra/net/ssrf.js";
import { logDebug } from "../../logger.js";
import type { RuntimeWebFetchFirecrawlMetadata } from "../../secrets/runtime-web-tools.js";
import { wrapExternalContent, wrapWebContent } from "../../security/external-content.js";
import { normalizeSecretInput } from "../../utils/normalize-secret-input.js";
import { stringEnum } from "../schema/typebox.js";
@ -71,7 +73,7 @@ type WebFetchConfig = NonNullable<OpenClawConfig["tools"]>["web"] extends infer
type FirecrawlFetchConfig =
| {
enabled?: boolean;
apiKey?: string;
apiKey?: unknown;
baseUrl?: string;
onlyMainContent?: boolean;
maxAgeMs?: number;
@ -136,10 +138,14 @@ function resolveFirecrawlConfig(fetch?: WebFetchConfig): FirecrawlFetchConfig {
}
function resolveFirecrawlApiKey(firecrawl?: FirecrawlFetchConfig): string | undefined {
const fromConfig =
firecrawl && "apiKey" in firecrawl && typeof firecrawl.apiKey === "string"
? normalizeSecretInput(firecrawl.apiKey)
: "";
const fromConfigRaw =
firecrawl && "apiKey" in firecrawl
? normalizeResolvedSecretInputString({
value: firecrawl.apiKey,
path: "tools.web.fetch.firecrawl.apiKey",
})
: undefined;
const fromConfig = normalizeSecretInput(fromConfigRaw);
const fromEnv = normalizeSecretInput(process.env.FIRECRAWL_API_KEY);
return fromConfig || fromEnv || undefined;
}
@ -712,6 +718,7 @@ function resolveFirecrawlEndpoint(baseUrl: string): string {
export function createWebFetchTool(options?: {
config?: OpenClawConfig;
sandboxed?: boolean;
runtimeFirecrawl?: RuntimeWebFetchFirecrawlMetadata;
}): AnyAgentTool | null {
const fetch = resolveFetchConfig(options?.config);
if (!resolveFetchEnabled({ fetch, sandboxed: options?.sandboxed })) {
@ -719,8 +726,14 @@ export function createWebFetchTool(options?: {
}
const readabilityEnabled = resolveFetchReadabilityEnabled(fetch);
const firecrawl = resolveFirecrawlConfig(fetch);
const firecrawlApiKey = resolveFirecrawlApiKey(firecrawl);
const firecrawlEnabled = resolveFirecrawlEnabled({ firecrawl, apiKey: firecrawlApiKey });
const runtimeFirecrawlActive = options?.runtimeFirecrawl?.active;
const shouldResolveFirecrawlApiKey =
runtimeFirecrawlActive === undefined ? firecrawl?.enabled !== false : runtimeFirecrawlActive;
const firecrawlApiKey = shouldResolveFirecrawlApiKey
? resolveFirecrawlApiKey(firecrawl)
: undefined;
const firecrawlEnabled =
runtimeFirecrawlActive ?? resolveFirecrawlEnabled({ firecrawl, apiKey: firecrawlApiKey });
const firecrawlBaseUrl = resolveFirecrawlBaseUrl(firecrawl);
const firecrawlOnlyMainContent = resolveFirecrawlOnlyMainContent(firecrawl);
const firecrawlMaxAgeMs = resolveFirecrawlMaxAgeMsOrDefault(firecrawl);

View File

@ -3,6 +3,7 @@ import { formatCliCommand } from "../../cli/command-format.js";
import type { OpenClawConfig } from "../../config/config.js";
import { normalizeResolvedSecretInputString } from "../../config/types.secrets.js";
import { logVerbose } from "../../globals.js";
import type { RuntimeWebSearchMetadata } from "../../secrets/runtime-web-tools.js";
import { wrapWebContent } from "../../security/external-content.js";
import { normalizeSecretInput } from "../../utils/normalize-secret-input.js";
import type { AnyAgentTool } from "./common.js";
@ -193,6 +194,33 @@ function createWebSearchSchema(params: {
),
} as const;
const perplexityStructuredFilterSchema = {
country: Type.Optional(
Type.String({
description:
"Native Perplexity Search API only. 2-letter country code for region-specific results (e.g., 'DE', 'US', 'ALL'). Default: 'US'.",
}),
),
language: Type.Optional(
Type.String({
description:
"Native Perplexity Search API only. ISO 639-1 language code for results (e.g., 'en', 'de', 'fr').",
}),
),
date_after: Type.Optional(
Type.String({
description:
"Native Perplexity Search API only. Only results published after this date (YYYY-MM-DD).",
}),
),
date_before: Type.Optional(
Type.String({
description:
"Native Perplexity Search API only. Only results published before this date (YYYY-MM-DD).",
}),
),
} as const;
if (params.provider === "brave") {
return Type.Object({
...querySchema,
@ -221,7 +249,8 @@ function createWebSearchSchema(params: {
}
return Type.Object({
...querySchema,
...filterSchema,
freshness: filterSchema.freshness,
...perplexityStructuredFilterSchema,
domain_filter: Type.Optional(
Type.Array(Type.String(), {
description:
@ -742,6 +771,16 @@ function resolvePerplexityTransport(perplexity?: PerplexityConfig): {
};
}
function resolvePerplexitySchemaTransportHint(
perplexity?: PerplexityConfig,
): PerplexityTransport | undefined {
const hasLegacyOverride = Boolean(
(perplexity?.baseUrl && perplexity.baseUrl.trim()) ||
(perplexity?.model && perplexity.model.trim()),
);
return hasLegacyOverride ? "chat_completions" : undefined;
}
function resolveGrokConfig(search?: WebSearchConfig): GrokConfig {
if (!search || typeof search !== "object") {
return {};
@ -1809,15 +1848,21 @@ async function runWebSearch(params: {
export function createWebSearchTool(options?: {
config?: OpenClawConfig;
sandboxed?: boolean;
runtimeWebSearch?: RuntimeWebSearchMetadata;
}): AnyAgentTool | null {
const search = resolveSearchConfig(options?.config);
if (!resolveSearchEnabled({ search, sandboxed: options?.sandboxed })) {
return null;
}
const provider = resolveSearchProvider(search);
const provider =
options?.runtimeWebSearch?.selectedProvider ??
options?.runtimeWebSearch?.providerConfigured ??
resolveSearchProvider(search);
const perplexityConfig = resolvePerplexityConfig(search);
const perplexityTransport = resolvePerplexityTransport(perplexityConfig);
const perplexitySchemaTransportHint =
options?.runtimeWebSearch?.perplexityTransport ??
resolvePerplexitySchemaTransportHint(perplexityConfig);
const grokConfig = resolveGrokConfig(search);
const geminiConfig = resolveGeminiConfig(search);
const kimiConfig = resolveKimiConfig(search);
@ -1826,9 +1871,9 @@ export function createWebSearchTool(options?: {
const description =
provider === "perplexity"
? perplexityTransport.transport === "chat_completions"
? perplexitySchemaTransportHint === "chat_completions"
? "Search the web using Perplexity Sonar via Perplexity/OpenRouter chat completions. Returns AI-synthesized answers with citations from web-grounded search."
: "Search the web using the Perplexity Search API. Returns structured results (title, URL, snippet) for fast research. Supports domain, region, language, and freshness filtering."
: "Search the web using Perplexity. Runtime routing decides between native Search API and Sonar chat-completions compatibility. Structured filters are available on the native Search API path."
: provider === "grok"
? "Search the web using xAI Grok. Returns AI-synthesized answers with citations from real-time web search."
: provider === "kimi"
@ -1845,10 +1890,13 @@ export function createWebSearchTool(options?: {
description,
parameters: createWebSearchSchema({
provider,
perplexityTransport: provider === "perplexity" ? perplexityTransport.transport : undefined,
perplexityTransport: provider === "perplexity" ? perplexitySchemaTransportHint : undefined,
}),
execute: async (_toolCallId, args) => {
const perplexityRuntime = provider === "perplexity" ? perplexityTransport : undefined;
// Resolve Perplexity auth/transport lazily at execution time so unrelated providers
// do not touch Perplexity-only credential surfaces during tool construction.
const perplexityRuntime =
provider === "perplexity" ? resolvePerplexityTransport(perplexityConfig) : undefined;
const apiKey =
provider === "perplexity"
? perplexityRuntime?.apiKey

View File

@ -166,6 +166,39 @@ describe("web tools defaults", () => {
const tool = createWebSearchTool({ config: {}, sandboxed: false });
expect(tool?.name).toBe("web_search");
});
it("prefers runtime-selected web_search provider over local provider config", async () => {
const mockFetch = installMockFetch(createProviderSuccessPayload("gemini"));
const tool = createWebSearchTool({
config: {
tools: {
web: {
search: {
provider: "brave",
apiKey: "brave-config-test", // pragma: allowlist secret
gemini: {
apiKey: "gemini-config-test", // pragma: allowlist secret
},
},
},
},
},
sandboxed: true,
runtimeWebSearch: {
providerConfigured: "brave",
providerSource: "auto-detect",
selectedProvider: "gemini",
selectedProviderKeySource: "secretRef",
diagnostics: [],
},
});
const result = await tool?.execute?.("call-runtime-provider", { query: "runtime override" });
expect(mockFetch).toHaveBeenCalled();
expect(String(mockFetch.mock.calls[0]?.[0])).toContain("generativelanguage.googleapis.com");
expect((result?.details as { provider?: string } | undefined)?.provider).toBe("gemini");
});
});
describe("web_search country and language parameters", () => {
@ -489,20 +522,56 @@ describe("web_search perplexity OpenRouter compatibility", () => {
expect(result?.details).toMatchObject({ error: "unsupported_domain_filter" });
});
it("hides Search API-only schema params on the compatibility path", () => {
it("keeps Search API schema params visible before runtime auth routing", () => {
vi.stubEnv("OPENROUTER_API_KEY", "sk-or-v1-test"); // pragma: allowlist secret
const tool = createPerplexitySearchTool();
const properties = (tool?.parameters as { properties?: Record<string, unknown> } | undefined)
?.properties;
expect(properties?.freshness).toBeDefined();
expect(properties?.country).toBeUndefined();
expect(properties?.language).toBeUndefined();
expect(properties?.date_after).toBeUndefined();
expect(properties?.date_before).toBeUndefined();
expect(properties?.domain_filter).toBeUndefined();
expect(properties?.max_tokens).toBeUndefined();
expect(properties?.max_tokens_per_page).toBeUndefined();
expect(properties?.country).toBeDefined();
expect(properties?.language).toBeDefined();
expect(properties?.date_after).toBeDefined();
expect(properties?.date_before).toBeDefined();
expect(properties?.domain_filter).toBeDefined();
expect(properties?.max_tokens).toBeDefined();
expect(properties?.max_tokens_per_page).toBeDefined();
expect(
(
properties?.country as
| {
description?: string;
}
| undefined
)?.description,
).toContain("Native Perplexity Search API only.");
expect(
(
properties?.language as
| {
description?: string;
}
| undefined
)?.description,
).toContain("Native Perplexity Search API only.");
expect(
(
properties?.date_after as
| {
description?: string;
}
| undefined
)?.description,
).toContain("Native Perplexity Search API only.");
expect(
(
properties?.date_before as
| {
description?: string;
}
| undefined
)?.description,
).toContain("Native Perplexity Search API only.");
});
it("keeps structured schema params on the native Search API path", () => {
@ -522,6 +591,61 @@ describe("web_search perplexity OpenRouter compatibility", () => {
});
});
describe("web_search Perplexity lazy resolution", () => {
const priorFetch = global.fetch;
afterEach(() => {
vi.unstubAllEnvs();
global.fetch = priorFetch;
});
it("does not read Perplexity credentials while creating non-Perplexity tools", () => {
const perplexityConfig: Record<string, unknown> = {};
Object.defineProperty(perplexityConfig, "apiKey", {
enumerable: true,
get() {
throw new Error("perplexity-apiKey-getter-called");
},
});
const tool = createWebSearchTool({
config: {
tools: {
web: {
search: {
provider: "gemini",
gemini: { apiKey: "gemini-config-test" },
perplexity: perplexityConfig as { apiKey?: string; baseUrl?: string; model?: string },
},
},
},
},
sandboxed: true,
});
expect(tool?.name).toBe("web_search");
});
it("defers Perplexity credential reads until execute", async () => {
const perplexityConfig: Record<string, unknown> = {};
Object.defineProperty(perplexityConfig, "apiKey", {
enumerable: true,
get() {
throw new Error("perplexity-apiKey-getter-called");
},
});
const tool = createPerplexitySearchTool(
perplexityConfig as { apiKey?: string; baseUrl?: string; model?: string },
);
expect(tool?.name).toBe("web_search");
await expect(tool?.execute?.("call-1", { query: "test" })).rejects.toThrow(
/perplexity-apiKey-getter-called/,
);
});
});
describe("web_search kimi provider", () => {
const priorFetch = global.fetch;

View File

@ -206,6 +206,119 @@ describe("resolveCommandSecretRefsViaGateway", () => {
}
});
it("falls back to local resolution for web search SecretRefs when gateway is unavailable", async () => {
const envKey = "WEB_SEARCH_GEMINI_API_KEY_LOCAL_FALLBACK";
const priorValue = process.env[envKey];
process.env[envKey] = "gemini-local-fallback-key";
callGateway.mockRejectedValueOnce(new Error("gateway closed"));
try {
const result = await resolveCommandSecretRefsViaGateway({
config: {
tools: {
web: {
search: {
provider: "gemini",
gemini: {
apiKey: { source: "env", provider: "default", id: envKey },
},
},
},
},
} as OpenClawConfig,
commandName: "agent",
targetIds: new Set(["tools.web.search.gemini.apiKey"]),
});
expect(result.resolvedConfig.tools?.web?.search?.gemini?.apiKey).toBe(
"gemini-local-fallback-key",
);
expect(result.targetStatesByPath["tools.web.search.gemini.apiKey"]).toBe("resolved_local");
expect(
result.diagnostics.some((entry) => entry.includes("gateway secrets.resolve unavailable")),
).toBe(true);
expect(
result.diagnostics.some((entry) => entry.includes("resolved command secrets locally")),
).toBe(true);
} finally {
if (priorValue === undefined) {
delete process.env[envKey];
} else {
process.env[envKey] = priorValue;
}
}
});
it("falls back to local resolution for Firecrawl SecretRefs when gateway is unavailable", async () => {
const envKey = "WEB_FETCH_FIRECRAWL_API_KEY_LOCAL_FALLBACK";
const priorValue = process.env[envKey];
process.env[envKey] = "firecrawl-local-fallback-key";
callGateway.mockRejectedValueOnce(new Error("gateway closed"));
try {
const result = await resolveCommandSecretRefsViaGateway({
config: {
tools: {
web: {
fetch: {
firecrawl: {
apiKey: { source: "env", provider: "default", id: envKey },
},
},
},
},
} as OpenClawConfig,
commandName: "agent",
targetIds: new Set(["tools.web.fetch.firecrawl.apiKey"]),
});
expect(result.resolvedConfig.tools?.web?.fetch?.firecrawl?.apiKey).toBe(
"firecrawl-local-fallback-key",
);
expect(result.targetStatesByPath["tools.web.fetch.firecrawl.apiKey"]).toBe("resolved_local");
expect(
result.diagnostics.some((entry) => entry.includes("gateway secrets.resolve unavailable")),
).toBe(true);
expect(
result.diagnostics.some((entry) => entry.includes("resolved command secrets locally")),
).toBe(true);
} finally {
if (priorValue === undefined) {
delete process.env[envKey];
} else {
process.env[envKey] = priorValue;
}
}
});
it("marks web SecretRefs inactive when the web surface is disabled during local fallback", async () => {
callGateway.mockRejectedValueOnce(new Error("gateway closed"));
const result = await resolveCommandSecretRefsViaGateway({
config: {
tools: {
web: {
search: {
enabled: false,
gemini: {
apiKey: { source: "env", provider: "default", id: "WEB_SEARCH_DISABLED_KEY" },
},
},
},
},
} as OpenClawConfig,
commandName: "agent",
targetIds: new Set(["tools.web.search.gemini.apiKey"]),
});
expect(result.hadUnresolvedTargets).toBe(false);
expect(result.targetStatesByPath["tools.web.search.gemini.apiKey"]).toBe("inactive_surface");
expect(
result.diagnostics.some((entry) =>
entry.includes("tools.web.search.gemini.apiKey: tools.web.search is disabled."),
),
).toBe(true);
});
it("returns a version-skew hint when gateway does not support secrets.resolve", async () => {
const envKey = "TALK_API_KEY_UNSUPPORTED";
callGateway.mockRejectedValueOnce(new Error("unknown method: secrets.resolve"));

View File

@ -10,6 +10,7 @@ import { getPath, setPathExistingStrict } from "../secrets/path-utils.js";
import { resolveSecretRefValue } from "../secrets/resolve.js";
import { collectConfigAssignments } from "../secrets/runtime-config-collectors.js";
import { createResolverContext } from "../secrets/runtime-shared.js";
import { resolveRuntimeWebTools } from "../secrets/runtime-web-tools.js";
import { assertExpectedResolvedSecretValue } from "../secrets/secret-value.js";
import { describeUnknownError } from "../secrets/shared.js";
import {
@ -44,6 +45,15 @@ type GatewaySecretsResolveResult = {
inactiveRefPaths?: string[];
};
const WEB_RUNTIME_SECRET_TARGET_ID_PREFIXES = [
"tools.web.search",
"tools.web.fetch.firecrawl",
] as const;
const WEB_RUNTIME_SECRET_PATH_PREFIXES = [
"tools.web.search.",
"tools.web.fetch.firecrawl.",
] as const;
function dedupeDiagnostics(entries: readonly string[]): string[] {
const seen = new Set<string>();
const ordered: string[] = [];
@ -58,6 +68,30 @@ function dedupeDiagnostics(entries: readonly string[]): string[] {
return ordered;
}
function targetsRuntimeWebPath(path: string): boolean {
return WEB_RUNTIME_SECRET_PATH_PREFIXES.some((prefix) => path.startsWith(prefix));
}
function targetsRuntimeWebResolution(params: {
targetIds: ReadonlySet<string>;
allowedPaths?: ReadonlySet<string>;
}): boolean {
if (params.allowedPaths) {
for (const path of params.allowedPaths) {
if (targetsRuntimeWebPath(path)) {
return true;
}
}
return false;
}
for (const targetId of params.targetIds) {
if (WEB_RUNTIME_SECRET_TARGET_ID_PREFIXES.some((prefix) => targetId.startsWith(prefix))) {
return true;
}
}
return false;
}
function collectConfiguredTargetRefPaths(params: {
config: OpenClawConfig;
targetIds: Set<string>;
@ -193,17 +227,40 @@ async function resolveCommandSecretRefsLocally(params: {
sourceConfig,
env: process.env,
});
const localResolutionDiagnostics: string[] = [];
collectConfigAssignments({
config: structuredClone(params.config),
context,
});
if (
targetsRuntimeWebResolution({ targetIds: params.targetIds, allowedPaths: params.allowedPaths })
) {
try {
await resolveRuntimeWebTools({
sourceConfig,
resolvedConfig,
context,
});
} catch (error) {
if (params.mode === "strict") {
throw error;
}
localResolutionDiagnostics.push(
`${params.commandName}: failed to resolve web tool secrets locally (${describeUnknownError(error)}).`,
);
}
}
const inactiveRefPaths = new Set(
context.warnings
.filter((warning) => warning.code === "SECRETS_REF_IGNORED_INACTIVE_SURFACE")
.filter((warning) => !params.allowedPaths || params.allowedPaths.has(warning.path))
.map((warning) => warning.path),
);
const inactiveWarningDiagnostics = context.warnings
.filter((warning) => warning.code === "SECRETS_REF_IGNORED_INACTIVE_SURFACE")
.filter((warning) => !params.allowedPaths || params.allowedPaths.has(warning.path))
.map((warning) => warning.message);
const activePaths = new Set(context.assignments.map((assignment) => assignment.path));
const localResolutionDiagnostics: string[] = [];
for (const target of discoverConfigSecretTargetsByIds(sourceConfig, params.targetIds)) {
if (params.allowedPaths && !params.allowedPaths.has(target.path)) {
continue;
@ -244,6 +301,7 @@ async function resolveCommandSecretRefsLocally(params: {
resolvedConfig,
diagnostics: dedupeDiagnostics([
...params.preflightDiagnostics,
...inactiveWarningDiagnostics,
...filterInactiveSurfaceDiagnostics({
diagnostics: analyzed.diagnostics,
inactiveRefPaths,

View File

@ -9,6 +9,7 @@ describe("command secret target ids", () => {
const ids = getAgentRuntimeCommandSecretTargetIds();
expect(ids.has("agents.defaults.memorySearch.remote.apiKey")).toBe(true);
expect(ids.has("agents.list[].memorySearch.remote.apiKey")).toBe(true);
expect(ids.has("tools.web.fetch.firecrawl.apiKey")).toBe(true);
});
it("keeps memory command target set focused on memorySearch remote credentials", () => {

View File

@ -23,6 +23,7 @@ const COMMAND_SECRET_TARGETS = {
"skills.entries.",
"messages.tts.",
"tools.web.search",
"tools.web.fetch.firecrawl.",
]),
status: idsByPrefix([
"channels.",

View File

@ -512,7 +512,7 @@ export type ToolsConfig = {
/** Enable Firecrawl fallback (default: true when apiKey is set). */
enabled?: boolean;
/** Firecrawl API key (optional; defaults to FIRECRAWL_API_KEY env var). */
apiKey?: string;
apiKey?: SecretInput;
/** Firecrawl base URL (default: https://api.firecrawl.dev). */
baseUrl?: string;
/** Whether to keep only main content (default: true). */

View File

@ -175,12 +175,14 @@ describe("gateway hot reload", () => {
let prevSkipGmail: string | undefined;
let prevSkipProviders: string | undefined;
let prevOpenAiApiKey: string | undefined;
let prevGeminiApiKey: string | undefined;
beforeEach(() => {
prevSkipChannels = process.env.OPENCLAW_SKIP_CHANNELS;
prevSkipGmail = process.env.OPENCLAW_SKIP_GMAIL_WATCHER;
prevSkipProviders = process.env.OPENCLAW_SKIP_PROVIDERS;
prevOpenAiApiKey = process.env.OPENAI_API_KEY;
prevGeminiApiKey = process.env.GEMINI_API_KEY;
process.env.OPENCLAW_SKIP_CHANNELS = "0";
delete process.env.OPENCLAW_SKIP_GMAIL_WATCHER;
delete process.env.OPENCLAW_SKIP_PROVIDERS;
@ -207,6 +209,11 @@ describe("gateway hot reload", () => {
} else {
process.env.OPENAI_API_KEY = prevOpenAiApiKey;
}
if (prevGeminiApiKey === undefined) {
delete process.env.GEMINI_API_KEY;
} else {
process.env.GEMINI_API_KEY = prevGeminiApiKey;
}
});
async function writeEnvRefConfig() {
@ -328,6 +335,34 @@ describe("gateway hot reload", () => {
);
}
async function writeWebSearchGeminiRefConfig() {
const configPath = process.env.OPENCLAW_CONFIG_PATH;
if (!configPath) {
throw new Error("OPENCLAW_CONFIG_PATH is not set");
}
await fs.writeFile(
configPath,
`${JSON.stringify(
{
tools: {
web: {
search: {
enabled: true,
provider: "gemini",
gemini: {
apiKey: { source: "env", provider: "default", id: "GEMINI_API_KEY" },
},
},
},
},
},
null,
2,
)}\n`,
"utf8",
);
}
async function removeMainAuthProfileStore() {
const stateDir = process.env.OPENCLAW_STATE_DIR;
if (!stateDir) {
@ -540,6 +575,64 @@ describe("gateway hot reload", () => {
});
});
it("emits one-shot degraded and recovered system events for web search secret reload transitions", async () => {
await writeWebSearchGeminiRefConfig();
process.env.GEMINI_API_KEY = "gemini-startup-key"; // pragma: allowlist secret
await withGatewayServer(async () => {
const onHotReload = hoisted.getOnHotReload();
expect(onHotReload).toBeTypeOf("function");
const sessionKey = resolveMainSessionKeyFromConfig();
const plan = {
changedPaths: ["tools.web.search.gemini.apiKey"],
restartGateway: false,
restartReasons: [],
hotReasons: ["tools.web.search.gemini.apiKey"],
reloadHooks: false,
restartGmailWatcher: false,
restartBrowserControl: false,
restartCron: false,
restartHeartbeat: false,
restartChannels: new Set(),
noopPaths: [],
};
const nextConfig = {
tools: {
web: {
search: {
enabled: true,
provider: "gemini",
gemini: {
apiKey: { source: "env", provider: "default", id: "GEMINI_API_KEY" },
},
},
},
},
};
delete process.env.GEMINI_API_KEY;
await expect(onHotReload?.(plan, nextConfig)).rejects.toThrow(
"[WEB_SEARCH_KEY_UNRESOLVED_NO_FALLBACK]",
);
const degradedEvents = drainSystemEvents(sessionKey);
expect(degradedEvents.some((event) => event.includes("[SECRETS_RELOADER_DEGRADED]"))).toBe(
true,
);
await expect(onHotReload?.(plan, nextConfig)).rejects.toThrow(
"[WEB_SEARCH_KEY_UNRESOLVED_NO_FALLBACK]",
);
expect(drainSystemEvents(sessionKey)).toEqual([]);
process.env.GEMINI_API_KEY = "gemini-recovered-key"; // pragma: allowlist secret
await expect(onHotReload?.(plan, nextConfig)).resolves.toBeUndefined();
const recoveredEvents = drainSystemEvents(sessionKey);
expect(recoveredEvents.some((event) => event.includes("[SECRETS_RELOADER_RECOVERED]"))).toBe(
true,
);
});
});
it("serves secrets.reload immediately after startup without race failures", async () => {
await writeEnvRefConfig();
process.env.OPENAI_API_KEY = "sk-startup"; // pragma: allowlist secret

View File

@ -292,67 +292,6 @@ function collectMessagesTtsAssignments(params: {
});
}
function collectToolsWebSearchAssignments(params: {
config: OpenClawConfig;
defaults: SecretDefaults | undefined;
context: ResolverContext;
}): void {
const tools = params.config.tools as Record<string, unknown> | undefined;
if (!isRecord(tools) || !isRecord(tools.web) || !isRecord(tools.web.search)) {
return;
}
const search = tools.web.search;
const searchEnabled = search.enabled !== false;
const rawProvider =
typeof search.provider === "string" ? search.provider.trim().toLowerCase() : "";
const selectedProvider =
rawProvider === "brave" ||
rawProvider === "gemini" ||
rawProvider === "grok" ||
rawProvider === "kimi" ||
rawProvider === "perplexity"
? rawProvider
: undefined;
const paths = [
"apiKey",
"gemini.apiKey",
"grok.apiKey",
"kimi.apiKey",
"perplexity.apiKey",
] as const;
for (const path of paths) {
const [scope, field] = path.includes(".") ? path.split(".", 2) : [undefined, path];
const target = scope ? search[scope] : search;
if (!isRecord(target)) {
continue;
}
const active = scope
? searchEnabled && (selectedProvider === undefined || selectedProvider === scope)
: searchEnabled && (selectedProvider === undefined || selectedProvider === "brave");
const inactiveReason = !searchEnabled
? "tools.web.search is disabled."
: scope
? selectedProvider === undefined
? undefined
: `tools.web.search.provider is "${selectedProvider}".`
: selectedProvider === undefined
? undefined
: `tools.web.search.provider is "${selectedProvider}".`;
collectSecretInputAssignment({
value: target[field],
path: `tools.web.search.${path}`,
expected: "string",
defaults: params.defaults,
context: params.context,
active,
inactiveReason,
apply: (value) => {
target[field] = value;
},
});
}
}
function collectCronAssignments(params: {
config: OpenClawConfig;
defaults: SecretDefaults | undefined;
@ -401,6 +340,5 @@ export function collectCoreConfigAssignments(params: {
collectTalkAssignments(params);
collectGatewayAssignments(params);
collectMessagesTtsAssignments(params);
collectToolsWebSearchAssignments(params);
collectCronAssignments(params);
}

View File

@ -7,7 +7,12 @@ import { isRecord } from "./shared.js";
export type SecretResolverWarningCode =
| "SECRETS_REF_OVERRIDES_PLAINTEXT"
| "SECRETS_REF_IGNORED_INACTIVE_SURFACE";
| "SECRETS_REF_IGNORED_INACTIVE_SURFACE"
| "WEB_SEARCH_PROVIDER_INVALID_AUTODETECT"
| "WEB_SEARCH_KEY_UNRESOLVED_FALLBACK_USED"
| "WEB_SEARCH_KEY_UNRESOLVED_NO_FALLBACK"
| "WEB_FETCH_FIRECRAWL_KEY_UNRESOLVED_FALLBACK_USED"
| "WEB_FETCH_FIRECRAWL_KEY_UNRESOLVED_NO_FALLBACK";
export type SecretResolverWarning = {
code: SecretResolverWarningCode;

View File

@ -0,0 +1,451 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import * as secretResolve from "./resolve.js";
import { createResolverContext } from "./runtime-shared.js";
import { resolveRuntimeWebTools } from "./runtime-web-tools.js";
type ProviderUnderTest = "brave" | "gemini" | "grok" | "kimi" | "perplexity";
function asConfig(value: unknown): OpenClawConfig {
return value as OpenClawConfig;
}
async function runRuntimeWebTools(params: { config: OpenClawConfig; env?: NodeJS.ProcessEnv }) {
const sourceConfig = structuredClone(params.config);
const resolvedConfig = structuredClone(params.config);
const context = createResolverContext({
sourceConfig,
env: params.env ?? {},
});
const metadata = await resolveRuntimeWebTools({
sourceConfig,
resolvedConfig,
context,
});
return { metadata, resolvedConfig, context };
}
function createProviderSecretRefConfig(
provider: ProviderUnderTest,
envRefId: string,
): OpenClawConfig {
const search: Record<string, unknown> = {
enabled: true,
provider,
};
if (provider === "brave") {
search.apiKey = { source: "env", provider: "default", id: envRefId };
} else {
search[provider] = {
apiKey: { source: "env", provider: "default", id: envRefId },
};
}
return asConfig({
tools: {
web: {
search,
},
},
});
}
function readProviderKey(config: OpenClawConfig, provider: ProviderUnderTest): unknown {
if (provider === "brave") {
return config.tools?.web?.search?.apiKey;
}
if (provider === "gemini") {
return config.tools?.web?.search?.gemini?.apiKey;
}
if (provider === "grok") {
return config.tools?.web?.search?.grok?.apiKey;
}
if (provider === "kimi") {
return config.tools?.web?.search?.kimi?.apiKey;
}
return config.tools?.web?.search?.perplexity?.apiKey;
}
describe("runtime web tools resolution", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it.each([
{
provider: "brave" as const,
envRefId: "BRAVE_PROVIDER_REF",
resolvedKey: "brave-provider-key",
},
{
provider: "gemini" as const,
envRefId: "GEMINI_PROVIDER_REF",
resolvedKey: "gemini-provider-key",
},
{
provider: "grok" as const,
envRefId: "GROK_PROVIDER_REF",
resolvedKey: "grok-provider-key",
},
{
provider: "kimi" as const,
envRefId: "KIMI_PROVIDER_REF",
resolvedKey: "kimi-provider-key",
},
{
provider: "perplexity" as const,
envRefId: "PERPLEXITY_PROVIDER_REF",
resolvedKey: "pplx-provider-key",
},
])(
"resolves configured provider SecretRef for $provider",
async ({ provider, envRefId, resolvedKey }) => {
const { metadata, resolvedConfig, context } = await runRuntimeWebTools({
config: createProviderSecretRefConfig(provider, envRefId),
env: {
[envRefId]: resolvedKey,
},
});
expect(metadata.search.providerConfigured).toBe(provider);
expect(metadata.search.providerSource).toBe("configured");
expect(metadata.search.selectedProvider).toBe(provider);
expect(metadata.search.selectedProviderKeySource).toBe("secretRef");
expect(readProviderKey(resolvedConfig, provider)).toBe(resolvedKey);
expect(context.warnings.map((warning) => warning.code)).not.toContain(
"WEB_SEARCH_KEY_UNRESOLVED_NO_FALLBACK",
);
if (provider === "perplexity") {
expect(metadata.search.perplexityTransport).toBe("search_api");
}
},
);
it("auto-detects provider precedence across all configured providers", async () => {
const { metadata, resolvedConfig, context } = await runRuntimeWebTools({
config: asConfig({
tools: {
web: {
search: {
apiKey: { source: "env", provider: "default", id: "BRAVE_REF" },
gemini: {
apiKey: { source: "env", provider: "default", id: "GEMINI_REF" },
},
grok: {
apiKey: { source: "env", provider: "default", id: "GROK_REF" },
},
kimi: {
apiKey: { source: "env", provider: "default", id: "KIMI_REF" },
},
perplexity: {
apiKey: { source: "env", provider: "default", id: "PERPLEXITY_REF" },
},
},
},
},
}),
env: {
BRAVE_REF: "brave-precedence-key",
GEMINI_REF: "gemini-precedence-key",
GROK_REF: "grok-precedence-key",
KIMI_REF: "kimi-precedence-key",
PERPLEXITY_REF: "pplx-precedence-key",
},
});
expect(metadata.search.providerSource).toBe("auto-detect");
expect(metadata.search.selectedProvider).toBe("brave");
expect(resolvedConfig.tools?.web?.search?.apiKey).toBe("brave-precedence-key");
expect(context.warnings).toEqual(
expect.arrayContaining([
expect.objectContaining({ path: "tools.web.search.gemini.apiKey" }),
expect.objectContaining({ path: "tools.web.search.grok.apiKey" }),
expect.objectContaining({ path: "tools.web.search.kimi.apiKey" }),
expect.objectContaining({ path: "tools.web.search.perplexity.apiKey" }),
]),
);
});
it("auto-detects first available provider and keeps lower-priority refs inactive", async () => {
const { metadata, resolvedConfig, context } = await runRuntimeWebTools({
config: asConfig({
tools: {
web: {
search: {
apiKey: { source: "env", provider: "default", id: "BRAVE_API_KEY_REF" },
gemini: {
apiKey: {
source: "env",
provider: "default",
id: "MISSING_GEMINI_API_KEY_REF",
},
},
},
},
},
}),
env: {
BRAVE_API_KEY_REF: "brave-runtime-key",
},
});
expect(metadata.search.providerSource).toBe("auto-detect");
expect(metadata.search.selectedProvider).toBe("brave");
expect(metadata.search.selectedProviderKeySource).toBe("secretRef");
expect(resolvedConfig.tools?.web?.search?.apiKey).toBe("brave-runtime-key");
expect(resolvedConfig.tools?.web?.search?.gemini?.apiKey).toEqual({
source: "env",
provider: "default",
id: "MISSING_GEMINI_API_KEY_REF",
});
expect(context.warnings).toEqual(
expect.arrayContaining([
expect.objectContaining({
code: "SECRETS_REF_IGNORED_INACTIVE_SURFACE",
path: "tools.web.search.gemini.apiKey",
}),
]),
);
expect(context.warnings.map((warning) => warning.code)).not.toContain(
"WEB_SEARCH_KEY_UNRESOLVED_NO_FALLBACK",
);
});
it("auto-detects the next provider when a higher-priority ref is unresolved", async () => {
const { metadata, resolvedConfig, context } = await runRuntimeWebTools({
config: asConfig({
tools: {
web: {
search: {
apiKey: { source: "env", provider: "default", id: "MISSING_BRAVE_API_KEY_REF" },
gemini: {
apiKey: { source: "env", provider: "default", id: "GEMINI_API_KEY_REF" },
},
},
},
},
}),
env: {
GEMINI_API_KEY_REF: "gemini-runtime-key",
},
});
expect(metadata.search.providerSource).toBe("auto-detect");
expect(metadata.search.selectedProvider).toBe("gemini");
expect(resolvedConfig.tools?.web?.search?.gemini?.apiKey).toBe("gemini-runtime-key");
expect(context.warnings).toEqual(
expect.arrayContaining([
expect.objectContaining({
code: "SECRETS_REF_IGNORED_INACTIVE_SURFACE",
path: "tools.web.search.apiKey",
}),
]),
);
expect(context.warnings.map((warning) => warning.code)).not.toContain(
"WEB_SEARCH_KEY_UNRESOLVED_NO_FALLBACK",
);
});
it("warns when provider is invalid and falls back to auto-detect", async () => {
const { metadata, resolvedConfig, context } = await runRuntimeWebTools({
config: asConfig({
tools: {
web: {
search: {
provider: "invalid-provider",
gemini: {
apiKey: { source: "env", provider: "default", id: "GEMINI_API_KEY_REF" },
},
},
},
},
}),
env: {
GEMINI_API_KEY_REF: "gemini-runtime-key",
},
});
expect(metadata.search.providerConfigured).toBeUndefined();
expect(metadata.search.providerSource).toBe("auto-detect");
expect(metadata.search.selectedProvider).toBe("gemini");
expect(resolvedConfig.tools?.web?.search?.gemini?.apiKey).toBe("gemini-runtime-key");
expect(metadata.search.diagnostics).toEqual(
expect.arrayContaining([
expect.objectContaining({
code: "WEB_SEARCH_PROVIDER_INVALID_AUTODETECT",
path: "tools.web.search.provider",
}),
]),
);
expect(context.warnings).toEqual(
expect.arrayContaining([
expect.objectContaining({
code: "WEB_SEARCH_PROVIDER_INVALID_AUTODETECT",
path: "tools.web.search.provider",
}),
]),
);
});
it("fails fast when configured provider ref is unresolved with no fallback", async () => {
const sourceConfig = asConfig({
tools: {
web: {
search: {
provider: "gemini",
gemini: {
apiKey: { source: "env", provider: "default", id: "MISSING_GEMINI_API_KEY_REF" },
},
},
},
},
});
const resolvedConfig = structuredClone(sourceConfig);
const context = createResolverContext({
sourceConfig,
env: {},
});
await expect(
resolveRuntimeWebTools({
sourceConfig,
resolvedConfig,
context,
}),
).rejects.toThrow("[WEB_SEARCH_KEY_UNRESOLVED_NO_FALLBACK]");
expect(context.warnings).toEqual(
expect.arrayContaining([
expect.objectContaining({
code: "WEB_SEARCH_KEY_UNRESOLVED_NO_FALLBACK",
path: "tools.web.search.gemini.apiKey",
}),
]),
);
});
it("does not resolve Firecrawl SecretRef when Firecrawl is inactive", async () => {
const resolveSpy = vi.spyOn(secretResolve, "resolveSecretRefValues");
const { metadata, context } = await runRuntimeWebTools({
config: asConfig({
tools: {
web: {
fetch: {
enabled: false,
firecrawl: {
apiKey: { source: "env", provider: "default", id: "MISSING_FIRECRAWL_REF" },
},
},
},
},
}),
});
expect(resolveSpy).not.toHaveBeenCalled();
expect(metadata.fetch.firecrawl.active).toBe(false);
expect(metadata.fetch.firecrawl.apiKeySource).toBe("secretRef");
expect(context.warnings).toEqual(
expect.arrayContaining([
expect.objectContaining({
code: "SECRETS_REF_IGNORED_INACTIVE_SURFACE",
path: "tools.web.fetch.firecrawl.apiKey",
}),
]),
);
});
it("does not resolve Firecrawl SecretRef when Firecrawl is disabled", async () => {
const resolveSpy = vi.spyOn(secretResolve, "resolveSecretRefValues");
const { metadata, context } = await runRuntimeWebTools({
config: asConfig({
tools: {
web: {
fetch: {
enabled: true,
firecrawl: {
enabled: false,
apiKey: { source: "env", provider: "default", id: "MISSING_FIRECRAWL_REF" },
},
},
},
},
}),
});
expect(resolveSpy).not.toHaveBeenCalled();
expect(metadata.fetch.firecrawl.active).toBe(false);
expect(metadata.fetch.firecrawl.apiKeySource).toBe("secretRef");
expect(context.warnings).toEqual(
expect.arrayContaining([
expect.objectContaining({
code: "SECRETS_REF_IGNORED_INACTIVE_SURFACE",
path: "tools.web.fetch.firecrawl.apiKey",
}),
]),
);
});
it("uses env fallback for unresolved Firecrawl SecretRef when active", async () => {
const { metadata, resolvedConfig, context } = await runRuntimeWebTools({
config: asConfig({
tools: {
web: {
fetch: {
firecrawl: {
apiKey: { source: "env", provider: "default", id: "MISSING_FIRECRAWL_REF" },
},
},
},
},
}),
env: {
FIRECRAWL_API_KEY: "firecrawl-fallback-key",
},
});
expect(metadata.fetch.firecrawl.active).toBe(true);
expect(metadata.fetch.firecrawl.apiKeySource).toBe("env");
expect(resolvedConfig.tools?.web?.fetch?.firecrawl?.apiKey).toBe("firecrawl-fallback-key");
expect(context.warnings).toEqual(
expect.arrayContaining([
expect.objectContaining({
code: "WEB_FETCH_FIRECRAWL_KEY_UNRESOLVED_FALLBACK_USED",
path: "tools.web.fetch.firecrawl.apiKey",
}),
]),
);
});
it("fails fast when active Firecrawl SecretRef is unresolved with no fallback", async () => {
const sourceConfig = asConfig({
tools: {
web: {
fetch: {
firecrawl: {
apiKey: { source: "env", provider: "default", id: "MISSING_FIRECRAWL_REF" },
},
},
},
},
});
const resolvedConfig = structuredClone(sourceConfig);
const context = createResolverContext({
sourceConfig,
env: {},
});
await expect(
resolveRuntimeWebTools({
sourceConfig,
resolvedConfig,
context,
}),
).rejects.toThrow("[WEB_FETCH_FIRECRAWL_KEY_UNRESOLVED_NO_FALLBACK]");
expect(context.warnings).toEqual(
expect.arrayContaining([
expect.objectContaining({
code: "WEB_FETCH_FIRECRAWL_KEY_UNRESOLVED_NO_FALLBACK",
path: "tools.web.fetch.firecrawl.apiKey",
}),
]),
);
});
});

View File

@ -0,0 +1,705 @@
import type { OpenClawConfig } from "../config/config.js";
import { resolveSecretInputRef } from "../config/types.secrets.js";
import { normalizeSecretInput } from "../utils/normalize-secret-input.js";
import { secretRefKey } from "./ref-contract.js";
import { resolveSecretRefValues } from "./resolve.js";
import {
pushInactiveSurfaceWarning,
pushWarning,
type ResolverContext,
type SecretDefaults,
} from "./runtime-shared.js";
const WEB_SEARCH_PROVIDERS = ["brave", "gemini", "grok", "kimi", "perplexity"] as const;
const PERPLEXITY_DIRECT_BASE_URL = "https://api.perplexity.ai";
const DEFAULT_PERPLEXITY_BASE_URL = "https://openrouter.ai/api/v1";
const PERPLEXITY_KEY_PREFIXES = ["pplx-"];
const OPENROUTER_KEY_PREFIXES = ["sk-or-"];
type WebSearchProvider = (typeof WEB_SEARCH_PROVIDERS)[number];
type SecretResolutionSource = "config" | "secretRef" | "env" | "missing";
type RuntimeWebProviderSource = "configured" | "auto-detect" | "none";
export type RuntimeWebDiagnosticCode =
| "WEB_SEARCH_PROVIDER_INVALID_AUTODETECT"
| "WEB_SEARCH_AUTODETECT_SELECTED"
| "WEB_SEARCH_KEY_UNRESOLVED_FALLBACK_USED"
| "WEB_SEARCH_KEY_UNRESOLVED_NO_FALLBACK"
| "WEB_FETCH_FIRECRAWL_KEY_UNRESOLVED_FALLBACK_USED"
| "WEB_FETCH_FIRECRAWL_KEY_UNRESOLVED_NO_FALLBACK";
export type RuntimeWebDiagnostic = {
code: RuntimeWebDiagnosticCode;
message: string;
path?: string;
};
export type RuntimeWebSearchMetadata = {
providerConfigured?: WebSearchProvider;
providerSource: RuntimeWebProviderSource;
selectedProvider?: WebSearchProvider;
selectedProviderKeySource?: SecretResolutionSource;
perplexityTransport?: "search_api" | "chat_completions";
diagnostics: RuntimeWebDiagnostic[];
};
export type RuntimeWebFetchFirecrawlMetadata = {
active: boolean;
apiKeySource: SecretResolutionSource;
diagnostics: RuntimeWebDiagnostic[];
};
export type RuntimeWebToolsMetadata = {
search: RuntimeWebSearchMetadata;
fetch: {
firecrawl: RuntimeWebFetchFirecrawlMetadata;
};
diagnostics: RuntimeWebDiagnostic[];
};
type FetchConfig = NonNullable<OpenClawConfig["tools"]>["web"] extends infer Web
? Web extends { fetch?: infer Fetch }
? Fetch
: undefined
: undefined;
type SecretResolutionResult = {
value?: string;
source: SecretResolutionSource;
secretRefConfigured: boolean;
unresolvedRefReason?: string;
fallbackEnvVar?: string;
fallbackUsedAfterRefFailure: boolean;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function normalizeProvider(value: unknown): WebSearchProvider | undefined {
if (typeof value !== "string") {
return undefined;
}
const normalized = value.trim().toLowerCase();
if (
normalized === "brave" ||
normalized === "gemini" ||
normalized === "grok" ||
normalized === "kimi" ||
normalized === "perplexity"
) {
return normalized;
}
return undefined;
}
function readNonEmptyEnvValue(
env: NodeJS.ProcessEnv,
names: string[],
): { value?: string; envVar?: string } {
for (const envVar of names) {
const value = normalizeSecretInput(env[envVar]);
if (value) {
return { value, envVar };
}
}
return {};
}
function buildUnresolvedReason(params: {
path: string;
kind: "unresolved" | "non-string" | "empty";
refLabel: string;
}): string {
if (params.kind === "non-string") {
return `${params.path} SecretRef resolved to a non-string value.`;
}
if (params.kind === "empty") {
return `${params.path} SecretRef resolved to an empty value.`;
}
return `${params.path} SecretRef is unresolved (${params.refLabel}).`;
}
async function resolveSecretInputWithEnvFallback(params: {
sourceConfig: OpenClawConfig;
context: ResolverContext;
defaults: SecretDefaults | undefined;
value: unknown;
path: string;
envVars: string[];
}): Promise<SecretResolutionResult> {
const { ref } = resolveSecretInputRef({
value: params.value,
defaults: params.defaults,
});
if (!ref) {
const configValue = normalizeSecretInput(params.value);
if (configValue) {
return {
value: configValue,
source: "config",
secretRefConfigured: false,
fallbackUsedAfterRefFailure: false,
};
}
const fallback = readNonEmptyEnvValue(params.context.env, params.envVars);
if (fallback.value) {
return {
value: fallback.value,
source: "env",
fallbackEnvVar: fallback.envVar,
secretRefConfigured: false,
fallbackUsedAfterRefFailure: false,
};
}
return {
source: "missing",
secretRefConfigured: false,
fallbackUsedAfterRefFailure: false,
};
}
const refLabel = `${ref.source}:${ref.provider}:${ref.id}`;
let resolvedFromRef: string | undefined;
let unresolvedRefReason: string | undefined;
try {
const resolved = await resolveSecretRefValues([ref], {
config: params.sourceConfig,
env: params.context.env,
cache: params.context.cache,
});
const resolvedValue = resolved.get(secretRefKey(ref));
if (typeof resolvedValue !== "string") {
unresolvedRefReason = buildUnresolvedReason({
path: params.path,
kind: "non-string",
refLabel,
});
} else {
resolvedFromRef = normalizeSecretInput(resolvedValue);
if (!resolvedFromRef) {
unresolvedRefReason = buildUnresolvedReason({
path: params.path,
kind: "empty",
refLabel,
});
}
}
} catch {
unresolvedRefReason = buildUnresolvedReason({
path: params.path,
kind: "unresolved",
refLabel,
});
}
if (resolvedFromRef) {
return {
value: resolvedFromRef,
source: "secretRef",
secretRefConfigured: true,
fallbackUsedAfterRefFailure: false,
};
}
const fallback = readNonEmptyEnvValue(params.context.env, params.envVars);
if (fallback.value) {
return {
value: fallback.value,
source: "env",
fallbackEnvVar: fallback.envVar,
unresolvedRefReason,
secretRefConfigured: true,
fallbackUsedAfterRefFailure: true,
};
}
return {
source: "missing",
unresolvedRefReason,
secretRefConfigured: true,
fallbackUsedAfterRefFailure: false,
};
}
function inferPerplexityBaseUrlFromApiKey(apiKey?: string): "direct" | "openrouter" | undefined {
if (!apiKey) {
return undefined;
}
const normalized = apiKey.toLowerCase();
if (PERPLEXITY_KEY_PREFIXES.some((prefix) => normalized.startsWith(prefix))) {
return "direct";
}
if (OPENROUTER_KEY_PREFIXES.some((prefix) => normalized.startsWith(prefix))) {
return "openrouter";
}
return undefined;
}
function resolvePerplexityRuntimeTransport(params: {
keyValue?: string;
keySource: SecretResolutionSource;
fallbackEnvVar?: string;
configValue: unknown;
}): "search_api" | "chat_completions" | undefined {
const config = isRecord(params.configValue) ? params.configValue : undefined;
const configuredBaseUrl = typeof config?.baseUrl === "string" ? config.baseUrl.trim() : "";
const configuredModel = typeof config?.model === "string" ? config.model.trim() : "";
const baseUrl = (() => {
if (configuredBaseUrl) {
return configuredBaseUrl;
}
if (params.keySource === "env") {
if (params.fallbackEnvVar === "PERPLEXITY_API_KEY") {
return PERPLEXITY_DIRECT_BASE_URL;
}
if (params.fallbackEnvVar === "OPENROUTER_API_KEY") {
return DEFAULT_PERPLEXITY_BASE_URL;
}
}
if ((params.keySource === "config" || params.keySource === "secretRef") && params.keyValue) {
const inferred = inferPerplexityBaseUrlFromApiKey(params.keyValue);
return inferred === "openrouter" ? DEFAULT_PERPLEXITY_BASE_URL : PERPLEXITY_DIRECT_BASE_URL;
}
return DEFAULT_PERPLEXITY_BASE_URL;
})();
const hasLegacyOverride = Boolean(configuredBaseUrl || configuredModel);
const direct = (() => {
try {
return new URL(baseUrl).hostname.toLowerCase() === "api.perplexity.ai";
} catch {
return false;
}
})();
return hasLegacyOverride || !direct ? "chat_completions" : "search_api";
}
function ensureObject(target: Record<string, unknown>, key: string): Record<string, unknown> {
const current = target[key];
if (isRecord(current)) {
return current;
}
const next: Record<string, unknown> = {};
target[key] = next;
return next;
}
function setResolvedWebSearchApiKey(params: {
resolvedConfig: OpenClawConfig;
provider: WebSearchProvider;
value: string;
}): void {
const tools = ensureObject(params.resolvedConfig as Record<string, unknown>, "tools");
const web = ensureObject(tools, "web");
const search = ensureObject(web, "search");
if (params.provider === "brave") {
search.apiKey = params.value;
return;
}
const providerConfig = ensureObject(search, params.provider);
providerConfig.apiKey = params.value;
}
function setResolvedFirecrawlApiKey(params: {
resolvedConfig: OpenClawConfig;
value: string;
}): void {
const tools = ensureObject(params.resolvedConfig as Record<string, unknown>, "tools");
const web = ensureObject(tools, "web");
const fetch = ensureObject(web, "fetch");
const firecrawl = ensureObject(fetch, "firecrawl");
firecrawl.apiKey = params.value;
}
function envVarsForProvider(provider: WebSearchProvider): string[] {
if (provider === "brave") {
return ["BRAVE_API_KEY"];
}
if (provider === "gemini") {
return ["GEMINI_API_KEY"];
}
if (provider === "grok") {
return ["XAI_API_KEY"];
}
if (provider === "kimi") {
return ["KIMI_API_KEY", "MOONSHOT_API_KEY"];
}
return ["PERPLEXITY_API_KEY", "OPENROUTER_API_KEY"];
}
function resolveProviderKeyValue(
search: Record<string, unknown>,
provider: WebSearchProvider,
): unknown {
if (provider === "brave") {
return search.apiKey;
}
const scoped = search[provider];
if (!isRecord(scoped)) {
return undefined;
}
return scoped.apiKey;
}
function hasConfiguredSecretRef(value: unknown, defaults: SecretDefaults | undefined): boolean {
return Boolean(
resolveSecretInputRef({
value,
defaults,
}).ref,
);
}
export async function resolveRuntimeWebTools(params: {
sourceConfig: OpenClawConfig;
resolvedConfig: OpenClawConfig;
context: ResolverContext;
}): Promise<RuntimeWebToolsMetadata> {
const defaults = params.sourceConfig.secrets?.defaults;
const diagnostics: RuntimeWebDiagnostic[] = [];
const tools = isRecord(params.sourceConfig.tools) ? params.sourceConfig.tools : undefined;
const web = isRecord(tools?.web) ? tools.web : undefined;
const search = isRecord(web?.search) ? web.search : undefined;
const searchMetadata: RuntimeWebSearchMetadata = {
providerSource: "none",
diagnostics: [],
};
const searchEnabled = search?.enabled !== false;
const rawProvider =
typeof search?.provider === "string" ? search.provider.trim().toLowerCase() : "";
const configuredProvider = normalizeProvider(rawProvider);
if (rawProvider && !configuredProvider) {
const diagnostic: RuntimeWebDiagnostic = {
code: "WEB_SEARCH_PROVIDER_INVALID_AUTODETECT",
message: `tools.web.search.provider is "${rawProvider}". Falling back to auto-detect precedence.`,
path: "tools.web.search.provider",
};
diagnostics.push(diagnostic);
searchMetadata.diagnostics.push(diagnostic);
pushWarning(params.context, {
code: "WEB_SEARCH_PROVIDER_INVALID_AUTODETECT",
path: "tools.web.search.provider",
message: diagnostic.message,
});
}
if (configuredProvider) {
searchMetadata.providerConfigured = configuredProvider;
searchMetadata.providerSource = "configured";
}
if (searchEnabled && search) {
const candidates = configuredProvider ? [configuredProvider] : [...WEB_SEARCH_PROVIDERS];
const unresolvedWithoutFallback: Array<{
provider: WebSearchProvider;
path: string;
reason: string;
}> = [];
let selectedProvider: WebSearchProvider | undefined;
let selectedResolution: SecretResolutionResult | undefined;
for (const provider of candidates) {
const path =
provider === "brave" ? "tools.web.search.apiKey" : `tools.web.search.${provider}.apiKey`;
const value = resolveProviderKeyValue(search, provider);
const resolution = await resolveSecretInputWithEnvFallback({
sourceConfig: params.sourceConfig,
context: params.context,
defaults,
value,
path,
envVars: envVarsForProvider(provider),
});
if (resolution.secretRefConfigured && resolution.fallbackUsedAfterRefFailure) {
const diagnostic: RuntimeWebDiagnostic = {
code: "WEB_SEARCH_KEY_UNRESOLVED_FALLBACK_USED",
message:
`${path} SecretRef could not be resolved; using ${resolution.fallbackEnvVar ?? "env fallback"}. ` +
(resolution.unresolvedRefReason ?? "").trim(),
path,
};
diagnostics.push(diagnostic);
searchMetadata.diagnostics.push(diagnostic);
pushWarning(params.context, {
code: "WEB_SEARCH_KEY_UNRESOLVED_FALLBACK_USED",
path,
message: diagnostic.message,
});
}
if (resolution.secretRefConfigured && !resolution.value && resolution.unresolvedRefReason) {
unresolvedWithoutFallback.push({
provider,
path,
reason: resolution.unresolvedRefReason,
});
}
if (configuredProvider) {
selectedProvider = provider;
selectedResolution = resolution;
if (resolution.value) {
setResolvedWebSearchApiKey({
resolvedConfig: params.resolvedConfig,
provider,
value: resolution.value,
});
}
break;
}
if (resolution.value) {
selectedProvider = provider;
selectedResolution = resolution;
setResolvedWebSearchApiKey({
resolvedConfig: params.resolvedConfig,
provider,
value: resolution.value,
});
break;
}
}
if (configuredProvider) {
const unresolved = unresolvedWithoutFallback[0];
if (unresolved) {
const diagnostic: RuntimeWebDiagnostic = {
code: "WEB_SEARCH_KEY_UNRESOLVED_NO_FALLBACK",
message: unresolved.reason,
path: unresolved.path,
};
diagnostics.push(diagnostic);
searchMetadata.diagnostics.push(diagnostic);
pushWarning(params.context, {
code: "WEB_SEARCH_KEY_UNRESOLVED_NO_FALLBACK",
path: unresolved.path,
message: unresolved.reason,
});
throw new Error(`[WEB_SEARCH_KEY_UNRESOLVED_NO_FALLBACK] ${unresolved.reason}`);
}
} else {
if (!selectedProvider && unresolvedWithoutFallback.length > 0) {
const unresolved = unresolvedWithoutFallback[0];
const diagnostic: RuntimeWebDiagnostic = {
code: "WEB_SEARCH_KEY_UNRESOLVED_NO_FALLBACK",
message: unresolved.reason,
path: unresolved.path,
};
diagnostics.push(diagnostic);
searchMetadata.diagnostics.push(diagnostic);
pushWarning(params.context, {
code: "WEB_SEARCH_KEY_UNRESOLVED_NO_FALLBACK",
path: unresolved.path,
message: unresolved.reason,
});
throw new Error(`[WEB_SEARCH_KEY_UNRESOLVED_NO_FALLBACK] ${unresolved.reason}`);
}
if (selectedProvider) {
const diagnostic: RuntimeWebDiagnostic = {
code: "WEB_SEARCH_AUTODETECT_SELECTED",
message: `tools.web.search auto-detected provider "${selectedProvider}" from available credentials.`,
path: "tools.web.search.provider",
};
diagnostics.push(diagnostic);
searchMetadata.diagnostics.push(diagnostic);
}
}
if (selectedProvider) {
searchMetadata.selectedProvider = selectedProvider;
searchMetadata.selectedProviderKeySource = selectedResolution?.source;
if (!configuredProvider) {
searchMetadata.providerSource = "auto-detect";
}
if (selectedProvider === "perplexity") {
searchMetadata.perplexityTransport = resolvePerplexityRuntimeTransport({
keyValue: selectedResolution?.value,
keySource: selectedResolution?.source ?? "missing",
fallbackEnvVar: selectedResolution?.fallbackEnvVar,
configValue: search.perplexity,
});
}
}
}
if (searchEnabled && search && !configuredProvider && searchMetadata.selectedProvider) {
for (const provider of WEB_SEARCH_PROVIDERS) {
if (provider === searchMetadata.selectedProvider) {
continue;
}
const path =
provider === "brave" ? "tools.web.search.apiKey" : `tools.web.search.${provider}.apiKey`;
const value = resolveProviderKeyValue(search, provider);
if (!hasConfiguredSecretRef(value, defaults)) {
continue;
}
pushInactiveSurfaceWarning({
context: params.context,
path,
details: `tools.web.search auto-detected provider is "${searchMetadata.selectedProvider}".`,
});
}
} else if (search && !searchEnabled) {
for (const provider of WEB_SEARCH_PROVIDERS) {
const path =
provider === "brave" ? "tools.web.search.apiKey" : `tools.web.search.${provider}.apiKey`;
const value = resolveProviderKeyValue(search, provider);
if (!hasConfiguredSecretRef(value, defaults)) {
continue;
}
pushInactiveSurfaceWarning({
context: params.context,
path,
details: "tools.web.search is disabled.",
});
}
}
if (searchEnabled && search && configuredProvider) {
for (const provider of WEB_SEARCH_PROVIDERS) {
if (provider === configuredProvider) {
continue;
}
const path =
provider === "brave" ? "tools.web.search.apiKey" : `tools.web.search.${provider}.apiKey`;
const value = resolveProviderKeyValue(search, provider);
if (!hasConfiguredSecretRef(value, defaults)) {
continue;
}
pushInactiveSurfaceWarning({
context: params.context,
path,
details: `tools.web.search.provider is "${configuredProvider}".`,
});
}
}
const fetch = isRecord(web?.fetch) ? (web.fetch as FetchConfig) : undefined;
const firecrawl = isRecord(fetch?.firecrawl) ? fetch.firecrawl : undefined;
const fetchEnabled = fetch?.enabled !== false;
const firecrawlEnabled = firecrawl?.enabled !== false;
const firecrawlActive = Boolean(fetchEnabled && firecrawlEnabled);
const firecrawlPath = "tools.web.fetch.firecrawl.apiKey";
let firecrawlResolution: SecretResolutionResult = {
source: "missing",
secretRefConfigured: false,
fallbackUsedAfterRefFailure: false,
};
const firecrawlDiagnostics: RuntimeWebDiagnostic[] = [];
if (firecrawlActive) {
firecrawlResolution = await resolveSecretInputWithEnvFallback({
sourceConfig: params.sourceConfig,
context: params.context,
defaults,
value: firecrawl?.apiKey,
path: firecrawlPath,
envVars: ["FIRECRAWL_API_KEY"],
});
if (firecrawlResolution.value) {
setResolvedFirecrawlApiKey({
resolvedConfig: params.resolvedConfig,
value: firecrawlResolution.value,
});
}
if (firecrawlResolution.secretRefConfigured) {
if (firecrawlResolution.fallbackUsedAfterRefFailure) {
const diagnostic: RuntimeWebDiagnostic = {
code: "WEB_FETCH_FIRECRAWL_KEY_UNRESOLVED_FALLBACK_USED",
message:
`${firecrawlPath} SecretRef could not be resolved; using ${firecrawlResolution.fallbackEnvVar ?? "env fallback"}. ` +
(firecrawlResolution.unresolvedRefReason ?? "").trim(),
path: firecrawlPath,
};
diagnostics.push(diagnostic);
firecrawlDiagnostics.push(diagnostic);
pushWarning(params.context, {
code: "WEB_FETCH_FIRECRAWL_KEY_UNRESOLVED_FALLBACK_USED",
path: firecrawlPath,
message: diagnostic.message,
});
}
if (!firecrawlResolution.value && firecrawlResolution.unresolvedRefReason) {
const diagnostic: RuntimeWebDiagnostic = {
code: "WEB_FETCH_FIRECRAWL_KEY_UNRESOLVED_NO_FALLBACK",
message: firecrawlResolution.unresolvedRefReason,
path: firecrawlPath,
};
diagnostics.push(diagnostic);
firecrawlDiagnostics.push(diagnostic);
pushWarning(params.context, {
code: "WEB_FETCH_FIRECRAWL_KEY_UNRESOLVED_NO_FALLBACK",
path: firecrawlPath,
message: firecrawlResolution.unresolvedRefReason,
});
throw new Error(
`[WEB_FETCH_FIRECRAWL_KEY_UNRESOLVED_NO_FALLBACK] ${firecrawlResolution.unresolvedRefReason}`,
);
}
}
} else {
if (hasConfiguredSecretRef(firecrawl?.apiKey, defaults)) {
pushInactiveSurfaceWarning({
context: params.context,
path: firecrawlPath,
details: !fetchEnabled
? "tools.web.fetch is disabled."
: "tools.web.fetch.firecrawl.enabled is false.",
});
firecrawlResolution = {
source: "secretRef",
secretRefConfigured: true,
fallbackUsedAfterRefFailure: false,
};
} else {
const configuredInlineValue = normalizeSecretInput(firecrawl?.apiKey);
if (configuredInlineValue) {
firecrawlResolution = {
value: configuredInlineValue,
source: "config",
secretRefConfigured: false,
fallbackUsedAfterRefFailure: false,
};
} else {
const envFallback = readNonEmptyEnvValue(params.context.env, ["FIRECRAWL_API_KEY"]);
if (envFallback.value) {
firecrawlResolution = {
value: envFallback.value,
source: "env",
fallbackEnvVar: envFallback.envVar,
secretRefConfigured: false,
fallbackUsedAfterRefFailure: false,
};
}
}
}
}
return {
search: searchMetadata,
fetch: {
firecrawl: {
active: firecrawlActive,
apiKeySource: firecrawlResolution.source,
diagnostics: firecrawlDiagnostics,
},
},
diagnostics,
};
}

View File

@ -8,6 +8,7 @@ import { withTempHome } from "../config/home-env.test-harness.js";
import {
activateSecretsRuntimeSnapshot,
clearSecretsRuntimeSnapshot,
getActiveRuntimeWebToolsMetadata,
getActiveSecretsRuntimeSnapshot,
prepareSecretsRuntimeSnapshot,
} from "./runtime.js";
@ -342,7 +343,7 @@ describe("secrets runtime snapshot", () => {
);
});
it("resolves provider-specific refs in web search auto mode", async () => {
it("keeps non-selected provider refs inactive in web search auto mode", async () => {
const snapshot = await prepareSecretsRuntimeSnapshot({
config: asConfig({
tools: {
@ -366,9 +367,19 @@ describe("secrets runtime snapshot", () => {
});
expect(snapshot.config.tools?.web?.search?.apiKey).toBe("web-search-ref");
expect(snapshot.config.tools?.web?.search?.gemini?.apiKey).toBe("web-search-gemini-ref");
expect(snapshot.warnings.map((warning) => warning.path)).not.toContain(
"tools.web.search.gemini.apiKey",
expect(snapshot.config.tools?.web?.search?.gemini?.apiKey).toEqual({
source: "env",
provider: "default",
id: "WEB_SEARCH_GEMINI_API_KEY",
});
expect(snapshot.webTools.search.selectedProvider).toBe("brave");
expect(snapshot.warnings).toEqual(
expect.arrayContaining([
expect.objectContaining({
code: "SECRETS_REF_IGNORED_INACTIVE_SURFACE",
path: "tools.web.search.gemini.apiKey",
}),
]),
);
});
@ -401,6 +412,71 @@ describe("secrets runtime snapshot", () => {
);
});
it("fails fast at startup when selected web search provider ref is unresolved", async () => {
await expect(
prepareSecretsRuntimeSnapshot({
config: asConfig({
tools: {
web: {
search: {
enabled: true,
provider: "gemini",
gemini: {
apiKey: {
source: "env",
provider: "default",
id: "MISSING_WEB_SEARCH_GEMINI_API_KEY",
},
},
},
},
},
}),
env: {},
agentDirs: ["/tmp/openclaw-agent-main"],
loadAuthStore: () => ({ version: 1, profiles: {} }),
}),
).rejects.toThrow("[WEB_SEARCH_KEY_UNRESOLVED_NO_FALLBACK]");
});
it("exposes active runtime web tool metadata as a defensive clone", async () => {
const snapshot = await prepareSecretsRuntimeSnapshot({
config: asConfig({
tools: {
web: {
search: {
provider: "gemini",
gemini: {
apiKey: { source: "env", provider: "default", id: "WEB_SEARCH_GEMINI_API_KEY" },
},
},
},
},
}),
env: {
WEB_SEARCH_GEMINI_API_KEY: "web-search-gemini-ref", // pragma: allowlist secret
},
agentDirs: ["/tmp/openclaw-agent-main"],
loadAuthStore: () => ({ version: 1, profiles: {} }),
});
activateSecretsRuntimeSnapshot(snapshot);
const first = getActiveRuntimeWebToolsMetadata();
expect(first?.search.providerConfigured).toBe("gemini");
expect(first?.search.selectedProvider).toBe("gemini");
expect(first?.search.selectedProviderKeySource).toBe("secretRef");
if (!first) {
throw new Error("missing runtime web tools metadata");
}
first.search.providerConfigured = "brave";
first.search.selectedProvider = "brave";
const second = getActiveRuntimeWebToolsMetadata();
expect(second?.search.providerConfigured).toBe("gemini");
expect(second?.search.selectedProvider).toBe("gemini");
});
it("resolves file refs via configured file provider", async () => {
if (process.platform === "win32") {
return;
@ -615,7 +691,7 @@ describe("secrets runtime snapshot", () => {
});
});
it("clears active secrets runtime state and throws when refresh fails after a write", async () => {
it("keeps last-known-good runtime snapshot active when refresh fails after a write", async () => {
if (os.platform() === "win32") {
return;
}
@ -704,9 +780,11 @@ describe("secrets runtime snapshot", () => {
/runtime snapshot refresh failed: simulated secrets runtime refresh failure/i,
);
expect(getActiveSecretsRuntimeSnapshot()).toBeNull();
expect(loadConfig().gateway?.auth).toEqual({ mode: "token" });
expect(loadConfig().models?.providers?.openai?.apiKey).toEqual({
const activeAfterFailure = getActiveSecretsRuntimeSnapshot();
expect(activeAfterFailure).not.toBeNull();
expect(loadConfig().gateway?.auth).toBeUndefined();
expect(loadConfig().models?.providers?.openai?.apiKey).toBe("sk-file-runtime");
expect(activeAfterFailure?.sourceConfig.models?.providers?.openai?.apiKey).toEqual({
source: "file",
provider: "default",
id: "/providers/openai/apiKey",
@ -715,9 +793,75 @@ describe("secrets runtime snapshot", () => {
const persistedStore = ensureAuthProfileStore(agentDir).profiles["openai:default"];
expect(persistedStore).toMatchObject({
type: "api_key",
keyRef: { source: "file", provider: "default", id: "/providers/openai/apiKey" },
key: "sk-file-runtime",
});
});
});
it("keeps last-known-good web runtime snapshot when reload introduces unresolved active web refs", async () => {
await withTempHome("openclaw-secrets-runtime-web-reload-lkg-", async (home) => {
const prepared = await prepareSecretsRuntimeSnapshot({
config: asConfig({
tools: {
web: {
search: {
provider: "gemini",
gemini: {
apiKey: { source: "env", provider: "default", id: "WEB_SEARCH_GEMINI_API_KEY" },
},
},
},
},
}),
env: {
WEB_SEARCH_GEMINI_API_KEY: "web-search-gemini-runtime-key", // pragma: allowlist secret
},
agentDirs: ["/tmp/openclaw-agent-main"],
loadAuthStore: () => ({ version: 1, profiles: {} }),
});
activateSecretsRuntimeSnapshot(prepared);
await expect(
writeConfigFile({
...loadConfig(),
tools: {
web: {
search: {
provider: "gemini",
gemini: {
apiKey: {
source: "env",
provider: "default",
id: "MISSING_WEB_SEARCH_GEMINI_API_KEY",
},
},
},
},
},
}),
).rejects.toThrow(
/runtime snapshot refresh failed: .*WEB_SEARCH_KEY_UNRESOLVED_NO_FALLBACK/i,
);
const activeAfterFailure = getActiveSecretsRuntimeSnapshot();
expect(activeAfterFailure).not.toBeNull();
expect(loadConfig().tools?.web?.search?.gemini?.apiKey).toBe("web-search-gemini-runtime-key");
expect(activeAfterFailure?.sourceConfig.tools?.web?.search?.gemini?.apiKey).toEqual({
source: "env",
provider: "default",
id: "WEB_SEARCH_GEMINI_API_KEY",
});
expect(getActiveRuntimeWebToolsMetadata()?.search.selectedProvider).toBe("gemini");
const persistedConfig = JSON.parse(
await fs.readFile(path.join(home, ".openclaw", "openclaw.json"), "utf8"),
) as OpenClawConfig;
expect(persistedConfig.tools?.web?.search?.gemini?.apiKey).toEqual({
source: "env",
provider: "default",
id: "MISSING_WEB_SEARCH_GEMINI_API_KEY",
});
expect("key" in persistedStore ? persistedStore.key : undefined).toBeUndefined();
});
});

View File

@ -25,6 +25,7 @@ import {
createResolverContext,
type SecretResolverWarning,
} from "./runtime-shared.js";
import { resolveRuntimeWebTools, type RuntimeWebToolsMetadata } from "./runtime-web-tools.js";
export type { SecretResolverWarning } from "./runtime-shared.js";
@ -33,6 +34,7 @@ export type PreparedSecretsRuntimeSnapshot = {
config: OpenClawConfig;
authStores: Array<{ agentDir: string; store: AuthProfileStore }>;
warnings: SecretResolverWarning[];
webTools: RuntimeWebToolsMetadata;
};
type SecretsRuntimeRefreshContext = {
@ -57,6 +59,7 @@ function cloneSnapshot(snapshot: PreparedSecretsRuntimeSnapshot): PreparedSecret
store: structuredClone(entry.store),
})),
warnings: snapshot.warnings.map((warning) => ({ ...warning })),
webTools: structuredClone(snapshot.webTools),
};
}
@ -148,6 +151,11 @@ export async function prepareSecretsRuntimeSnapshot(params: {
config: resolvedConfig,
authStores,
warnings: context.warnings,
webTools: await resolveRuntimeWebTools({
sourceConfig,
resolvedConfig,
context,
}),
};
preparedSnapshotRefreshContext.set(snapshot, {
env: { ...(params.env ?? process.env) } as Record<string, string | undefined>,
@ -185,7 +193,6 @@ export function activateSecretsRuntimeSnapshot(snapshot: PreparedSecretsRuntimeS
activateSecretsRuntimeSnapshot(refreshed);
return true;
},
clearOnRefreshFailure: clearActiveSecretsRuntimeState,
});
}
@ -200,6 +207,13 @@ export function getActiveSecretsRuntimeSnapshot(): PreparedSecretsRuntimeSnapsho
return snapshot;
}
export function getActiveRuntimeWebToolsMetadata(): RuntimeWebToolsMetadata | null {
if (!activeSnapshot) {
return null;
}
return structuredClone(activeSnapshot.webTools);
}
export function resolveCommandSecretsFromActiveRuntimeSnapshot(params: {
commandName: string;
targetIds: ReadonlySet<string>;

View File

@ -689,6 +689,17 @@ const SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [
includeInConfigure: true,
includeInAudit: true,
},
{
id: "tools.web.fetch.firecrawl.apiKey",
targetType: "tools.web.fetch.firecrawl.apiKey",
configFile: "openclaw.json",
pathPattern: "tools.web.fetch.firecrawl.apiKey",
secretShape: SECRET_INPUT_SHAPE,
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "tools.web.search.apiKey",
targetType: "tools.web.search.apiKey",