Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled

Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11),
free to diverge. Tree copied sans upstream .git; upstream remote added for
future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19.
Preserves docs/ARCHITECTURE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
2026-07-05 09:36:54 +00:00
parent 3216769225
commit bedb527145
21108 changed files with 6010766 additions and 0 deletions

View File

@@ -0,0 +1,36 @@
# @openclaw/brave-plugin
Official Brave Search provider plugin for OpenClaw.
This plugin registers Brave as a `web_search` provider. It supports normal Brave web search and Brave LLM Context API mode.
## Install
```bash
openclaw plugins install @openclaw/brave-plugin
```
Restart the Gateway after installing or updating the plugin.
## Configure
Store a Brave Search API key in plugin config or expose `BRAVE_API_KEY` to the Gateway:
```bash
openclaw config set plugins.entries.brave.enabled true
openclaw config set tools.web.search.provider brave
```
Provider-specific options live under `plugins.entries.brave.config.webSearch.*`.
## Docs
Full setup, config examples, search modes, and tool parameters:
- https://docs.openclaw.ai/tools/brave-search
## Package
- Plugin id: `brave`
- Package: `@openclaw/brave-plugin`
- Minimum OpenClaw host: `2026.4.10`

16
extensions/brave/index.ts Normal file
View File

@@ -0,0 +1,16 @@
/**
* Brave Search plugin entry. It registers the Brave web-search provider and
* keeps runtime HTTP execution lazy.
*/
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { createBraveWebSearchProvider } from "./src/brave-web-search-provider.js";
/** Plugin entry for Brave Search. */
export default definePluginEntry({
id: "brave",
name: "Brave Plugin",
description: "Bundled Brave plugin",
register(api) {
api.registerWebSearchProvider(createBraveWebSearchProvider());
},
});

12
extensions/brave/npm-shrinkwrap.json generated Normal file
View File

@@ -0,0 +1,12 @@
{
"name": "@openclaw/brave-plugin",
"version": "2026.6.11",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@openclaw/brave-plugin",
"version": "2026.6.11"
}
}
}

View File

@@ -0,0 +1,62 @@
{
"id": "brave",
"name": "Brave",
"description": "OpenClaw Brave Search provider plugin for web search.",
"icon": "https://cdn.simpleicons.org/brave",
"activation": {
"onStartup": false
},
"setup": {
"providers": [
{
"id": "brave",
"authMethods": ["api-key"],
"envVars": ["BRAVE_API_KEY"]
}
]
},
"uiHints": {
"webSearch.apiKey": {
"label": "Brave Search API Key",
"help": "Brave Search API key (fallback: BRAVE_API_KEY env var).",
"sensitive": true,
"placeholder": "BSA..."
},
"webSearch.mode": {
"label": "Brave Search Mode",
"help": "Brave Search mode: web or llm-context."
},
"webSearch.baseUrl": {
"label": "Brave Search Base URL",
"help": "Optional Brave-compatible API base URL for trusted proxies. Defaults to https://api.search.brave.com."
}
},
"contracts": {
"webSearchProviders": ["brave"]
},
"configContracts": {
"compatibilityRuntimePaths": ["tools.web.search.apiKey"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"webSearch": {
"type": "object",
"additionalProperties": false,
"properties": {
"apiKey": {
"type": ["string", "object"]
},
"mode": {
"type": "string",
"enum": ["web", "llm-context"]
},
"baseUrl": {
"type": ["string", "object"]
}
}
}
}
}
}

View File

@@ -0,0 +1,34 @@
{
"name": "@openclaw/brave-plugin",
"version": "2026.6.11",
"description": "OpenClaw Brave Search provider plugin for web search.",
"repository": {
"type": "git",
"url": "https://github.com/openclaw/openclaw"
},
"type": "module",
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*"
},
"openclaw": {
"extensions": [
"./index.ts"
],
"install": {
"npmSpec": "@openclaw/brave-plugin",
"defaultChoice": "npm",
"minHostVersion": ">=2026.4.10",
"allowInvalidConfigRecovery": true
},
"compat": {
"pluginApi": ">=2026.6.11"
},
"build": {
"openclawVersion": "2026.6.11"
},
"release": {
"publishToClawHub": true,
"publishToNpm": true
}
}
}

View File

@@ -0,0 +1,52 @@
// Brave tests cover brave web search provider.merge plugin behavior.
import { describe, expect, it, vi } from "vitest";
import { createBraveWebSearchProvider } from "./brave-web-search-provider.js";
const runtimeMock = vi.hoisted(() => {
const searchConfigs: Array<Record<string, unknown> | undefined> = [];
return {
searchConfigs,
executeBraveSearch: vi.fn(async (_args: unknown, searchConfig?: Record<string, unknown>) => {
searchConfigs.push(searchConfig);
return { results: [] };
}),
};
});
vi.mock("./brave-web-search-provider.runtime.js", () => ({
executeBraveSearch: runtimeMock.executeBraveSearch,
}));
describe("brave web search config merge", () => {
it("keeps plugin webSearch runtime-only after merging it for the tool", async () => {
const provider = createBraveWebSearchProvider();
const tool = provider.createTool({
config: {
plugins: {
entries: {
brave: {
config: {
webSearch: {
apiKey: "brave-test-key",
mode: "llm-context",
},
},
},
},
},
},
searchConfig: { provider: "brave" },
});
await tool?.execute({ query: "OpenClaw docs" });
const [searchConfig] = runtimeMock.searchConfigs;
expect(searchConfig?.brave).toEqual({
apiKey: "brave-test-key",
mode: "llm-context",
});
expect(searchConfig?.apiKey).toBe("brave-test-key");
expect(Object.keys(searchConfig ?? {})).toEqual(["provider", "apiKey"]);
expect(Object.getOwnPropertyDescriptor(searchConfig ?? {}, "brave")?.enumerable).toBe(false);
});
});

View File

@@ -0,0 +1,560 @@
/**
* Brave Search HTTP runtime. It resolves credentials, enforces endpoint safety,
* applies caching, and maps Brave web/LLM-context API responses.
*/
import {
assertOkOrThrowProviderError,
readProviderJsonResponse,
} from "openclaw/plugin-sdk/provider-http";
import type { SearchConfigRecord } from "openclaw/plugin-sdk/provider-web-search";
import {
buildSearchCacheKey,
DEFAULT_SEARCH_COUNT,
formatCliCommand,
MAX_SEARCH_COUNT,
parseWebSearchTimeFilters,
readCachedSearchPayload,
readConfiguredSecretString,
readPositiveIntegerParam,
readProviderEnvValue,
readStringParam,
resolveSearchCacheTtlMs,
resolveSearchCount,
resolveSearchTimeoutSeconds,
resolveSiteName,
withSelfHostedWebSearchEndpoint,
withTrustedWebSearchEndpoint,
wrapWebContent,
writeCachedSearchPayload,
} from "openclaw/plugin-sdk/provider-web-search";
import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
import {
assertHttpUrlTargetsPrivateNetwork,
isBlockedHostnameOrIp,
isPrivateIpAddress,
resolvePinnedHostnameWithPolicy,
} from "openclaw/plugin-sdk/ssrf-runtime";
import {
type BraveLlmContextResponse,
mapBraveLlmContextResults,
normalizeBraveCountry,
normalizeBraveLanguageParams,
resolveBraveConfig,
resolveBraveMode,
} from "./brave-web-search-provider.shared.js";
const DEFAULT_BRAVE_BASE_URL = "https://api.search.brave.com";
const BRAVE_SEARCH_ENDPOINT_PATH = "/res/v1/web/search";
const BRAVE_LLM_CONTEXT_ENDPOINT_PATH = "/res/v1/llm/context";
const braveHttpLogger = createSubsystemLogger("brave/http");
type BraveEndpointMode = "selfHosted" | "strict";
type BraveSearchMode = "llm-context" | "web";
type BraveSearchResult = {
title?: string;
url?: string;
description?: string;
age?: string;
};
type BraveSearchResponse = {
web?: {
results?: BraveSearchResult[];
};
};
type BraveHttpDiagnostics = {
enabled?: boolean;
};
function logBraveHttp(
diagnostics: BraveHttpDiagnostics | undefined,
event: string,
meta?: Record<string, unknown>,
): void {
if (!diagnostics?.enabled) {
return;
}
braveHttpLogger.info(`brave http ${event}`, meta);
}
function describeBraveRequestUrl(url: URL): {
url: string;
query: string;
params: Record<string, string>;
} {
return {
url: url.toString(),
query: url.searchParams.get("q") ?? "",
params: Object.fromEntries(url.searchParams.entries()),
};
}
function resolveBraveApiKey(searchConfig?: SearchConfigRecord): string | undefined {
return (
readConfiguredSecretString(searchConfig?.apiKey, "tools.web.search.apiKey") ??
readProviderEnvValue(["BRAVE_API_KEY"])
);
}
function resolveBraveBaseUrl(braveConfig: { baseUrl?: unknown } | undefined): string {
const configured = readConfiguredSecretString(
braveConfig?.baseUrl,
"plugins.entries.brave.config.webSearch.baseUrl",
);
return configured?.replace(/\/+$/u, "") || DEFAULT_BRAVE_BASE_URL;
}
function buildBraveEndpointUrl(params: { baseUrl: string; endpointPath: string }): URL {
const url = new URL(params.baseUrl);
const basePath = url.pathname.replace(/\/+$/u, "");
url.pathname = `${basePath}${params.endpointPath}`;
url.search = "";
return url;
}
async function braveEndpointTargetsPrivateNetwork(url: URL): Promise<boolean> {
if (isBlockedHostnameOrIp(url.hostname)) {
return true;
}
try {
const pinned = await resolvePinnedHostnameWithPolicy(url.hostname, {
policy: {
allowPrivateNetwork: true,
allowRfc2544BenchmarkRange: true,
},
});
return pinned.addresses.every((address) => isPrivateIpAddress(address));
} catch {
return false;
}
}
async function validateBraveBaseUrl(baseUrl: string): Promise<BraveEndpointMode> {
let parsed: URL;
try {
parsed = new URL(baseUrl);
} catch {
throw new Error("Brave Search base URL must be a valid http:// or https:// URL.");
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new Error("Brave Search base URL must use http:// or https://.");
}
if (parsed.protocol === "http:") {
await assertHttpUrlTargetsPrivateNetwork(parsed.toString(), {
dangerouslyAllowPrivateNetwork: true,
errorMessage:
"Brave Search HTTP base URL must target a trusted private or loopback host. Use https:// for public hosts.",
});
return "selfHosted";
}
return (await braveEndpointTargetsPrivateNetwork(parsed)) ? "selfHosted" : "strict";
}
function missingBraveKeyPayload() {
return {
error: "missing_brave_api_key",
message: `web_search (brave) needs a Brave Search API key. Run \`${formatCliCommand("openclaw configure --section web")}\` to store it, or set BRAVE_API_KEY in the Gateway environment. If you do not want to configure a search API key, use web_fetch for a specific URL or the browser tool for interactive pages.`,
docs: "https://docs.openclaw.ai/tools/web",
};
}
function setBraveSearchUrlParams(
url: URL,
params: {
query: string;
country?: string;
search_lang?: string;
freshness?: string;
dateAfter?: string;
dateBefore?: string;
allowDateBeforeOnly?: boolean;
},
): void {
url.searchParams.set("q", params.query);
if (params.country) {
url.searchParams.set("country", params.country);
}
if (params.search_lang) {
url.searchParams.set("search_lang", params.search_lang);
}
if (params.freshness) {
url.searchParams.set("freshness", params.freshness);
} else if (params.dateAfter && params.dateBefore) {
url.searchParams.set("freshness", `${params.dateAfter}to${params.dateBefore}`);
} else if (params.dateAfter) {
url.searchParams.set(
"freshness",
`${params.dateAfter}to${new Date().toISOString().slice(0, 10)}`,
);
} else if (params.allowDateBeforeOnly && params.dateBefore) {
url.searchParams.set("freshness", `1970-01-01to${params.dateBefore}`);
}
}
async function runBraveJsonRequest<T>(
params: {
baseUrl: string;
endpointPath: string;
endpointMode: BraveEndpointMode;
mode: BraveSearchMode;
apiKey: string;
timeoutSeconds: number;
diagnostics?: BraveHttpDiagnostics;
configureUrl: (url: URL) => void;
},
errorLabel: string,
): Promise<T> {
const url = buildBraveEndpointUrl({
baseUrl: params.baseUrl,
endpointPath: params.endpointPath,
});
params.configureUrl(url);
logBraveHttp(params.diagnostics, "request", {
mode: params.mode,
...describeBraveRequestUrl(url),
});
const startedAt = Date.now();
const withEndpoint =
params.endpointMode === "selfHosted"
? withSelfHostedWebSearchEndpoint
: withTrustedWebSearchEndpoint;
return withEndpoint(
{
url: url.toString(),
timeoutSeconds: params.timeoutSeconds,
init: {
method: "GET",
headers: {
Accept: "application/json",
"X-Subscription-Token": params.apiKey,
},
},
},
async (response) => {
logBraveHttp(params.diagnostics, "response", {
mode: params.mode,
status: response.status,
ok: response.ok,
durationMs: Date.now() - startedAt,
});
await assertOkOrThrowProviderError(response, errorLabel);
return readProviderJsonResponse<T>(response, errorLabel);
},
);
}
async function runBraveLlmContextSearch(params: {
baseUrl: string;
endpointMode: BraveEndpointMode;
query: string;
apiKey: string;
timeoutSeconds: number;
diagnostics?: BraveHttpDiagnostics;
country?: string;
search_lang?: string;
freshness?: string;
dateAfter?: string;
dateBefore?: string;
}): Promise<{
results: Array<{
url: string;
title: string;
snippets: string[];
siteName?: string;
}>;
sources?: BraveLlmContextResponse["sources"];
}> {
const data = await runBraveJsonRequest<BraveLlmContextResponse>(
{
baseUrl: params.baseUrl,
endpointPath: BRAVE_LLM_CONTEXT_ENDPOINT_PATH,
mode: "llm-context",
endpointMode: params.endpointMode,
apiKey: params.apiKey,
timeoutSeconds: params.timeoutSeconds,
diagnostics: params.diagnostics,
configureUrl: (url) => {
setBraveSearchUrlParams(url, params);
},
},
"Brave LLM Context API error",
);
return { results: mapBraveLlmContextResults(data), sources: data.sources };
}
async function runBraveWebSearch(params: {
baseUrl: string;
endpointMode: BraveEndpointMode;
query: string;
count: number;
apiKey: string;
timeoutSeconds: number;
diagnostics?: BraveHttpDiagnostics;
country?: string;
search_lang?: string;
ui_lang?: string;
freshness?: string;
dateAfter?: string;
dateBefore?: string;
}): Promise<Array<Record<string, unknown>>> {
const data = await runBraveJsonRequest<BraveSearchResponse>(
{
baseUrl: params.baseUrl,
endpointPath: BRAVE_SEARCH_ENDPOINT_PATH,
mode: "web",
endpointMode: params.endpointMode,
apiKey: params.apiKey,
timeoutSeconds: params.timeoutSeconds,
diagnostics: params.diagnostics,
configureUrl: (url) => {
setBraveSearchUrlParams(url, {
...params,
allowDateBeforeOnly: true,
});
url.searchParams.set("count", String(params.count));
if (params.ui_lang) {
url.searchParams.set("ui_lang", params.ui_lang);
}
},
},
"Brave Search API error",
);
const results = Array.isArray(data.web?.results) ? (data.web?.results ?? []) : [];
return results.map((entry) => {
const description = entry.description ?? "";
const title = entry.title ?? "";
const url = entry.url ?? "";
return {
title: title ? wrapWebContent(title, "web_search") : "",
url,
description: description ? wrapWebContent(description, "web_search") : "",
published: entry.age || undefined,
siteName: resolveSiteName(url) || undefined,
};
});
}
/** Execute one Brave Search request using web or LLM-context mode. */
export async function executeBraveSearch(
args: Record<string, unknown>,
searchConfig?: SearchConfigRecord,
options?: {
diagnosticsEnabled?: boolean;
},
): Promise<Record<string, unknown>> {
const apiKey = resolveBraveApiKey(searchConfig);
if (!apiKey) {
return missingBraveKeyPayload();
}
const braveConfig = resolveBraveConfig(searchConfig);
const braveMode = resolveBraveMode(braveConfig);
const braveBaseUrl = resolveBraveBaseUrl(braveConfig);
const braveEndpointMode = await validateBraveBaseUrl(braveBaseUrl);
const query = readStringParam(args, "query", { required: true });
const count =
readPositiveIntegerParam(args, "count", {
max: MAX_SEARCH_COUNT,
message: `count must be an integer from 1 to ${MAX_SEARCH_COUNT}.`,
}) ??
searchConfig?.maxResults ??
undefined;
const country = normalizeBraveCountry(readStringParam(args, "country"));
const language = readStringParam(args, "language");
const search_lang = readStringParam(args, "search_lang");
const ui_lang = readStringParam(args, "ui_lang");
const normalizedLanguage = normalizeBraveLanguageParams({
search_lang: search_lang || language,
ui_lang,
});
if (normalizedLanguage.invalidField === "search_lang") {
return {
error: "invalid_search_lang",
message:
"search_lang must be a Brave-supported language code like 'en', 'en-gb', 'zh-hans', or 'zh-hant'.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
if (normalizedLanguage.invalidField === "ui_lang") {
return {
error: "invalid_ui_lang",
message: "ui_lang must be a language-region locale like 'en-US'.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
if (normalizedLanguage.ui_lang && braveMode === "llm-context") {
return {
error: "unsupported_ui_lang",
message:
"ui_lang is not supported by Brave llm-context mode. Remove ui_lang or use Brave web mode for locale-based UI hints.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
const rawFreshness = readStringParam(args, "freshness");
const rawDateAfter = readStringParam(args, "date_after");
const rawDateBefore = readStringParam(args, "date_before");
const parsedTimeFilters = parseWebSearchTimeFilters({
rawDateAfter,
rawDateBefore,
rawFreshness,
freshnessProvider: "brave",
invalidFreshnessMessage: "freshness must be day, week, month, or year.",
invalidDateAfterMessage: "date_after must be YYYY-MM-DD format.",
invalidDateBeforeMessage: "date_before must be YYYY-MM-DD format.",
invalidDateRangeMessage: "date_after must be before date_before.",
});
if ("error" in parsedTimeFilters) {
return parsedTimeFilters;
}
const { freshness, dateAfter, dateBefore } = parsedTimeFilters;
if (braveMode === "llm-context") {
const today = new Date().toISOString().slice(0, 10);
if (dateAfter && !dateBefore && dateAfter > today) {
return {
error: "invalid_date_range",
message: "date_after cannot be in the future for Brave llm-context mode.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
if (dateBefore && !dateAfter) {
return {
error: "unsupported_date_filter",
message:
"Brave llm-context mode requires date_after when date_before is set. Use a bounded date range or freshness.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
}
const llmContextDateEnd =
braveMode === "llm-context" && dateAfter
? (dateBefore ?? new Date().toISOString().slice(0, 10))
: dateBefore;
const cacheKey = buildSearchCacheKey(
braveMode === "llm-context"
? [
"brave",
braveMode,
braveBaseUrl,
query,
country,
normalizedLanguage.search_lang,
freshness,
dateAfter,
llmContextDateEnd,
]
: [
"brave",
braveMode,
braveBaseUrl,
query,
resolveSearchCount(count, DEFAULT_SEARCH_COUNT),
country,
normalizedLanguage.search_lang,
normalizedLanguage.ui_lang,
freshness,
dateAfter,
dateBefore,
],
);
const diagnostics: BraveHttpDiagnostics = { enabled: options?.diagnosticsEnabled === true };
const cached = readCachedSearchPayload(cacheKey);
if (cached) {
logBraveHttp(diagnostics, "cache hit", { mode: braveMode, query, cacheKey });
return cached;
}
logBraveHttp(diagnostics, "cache miss", { mode: braveMode, query, cacheKey });
const start = Date.now();
const timeoutSeconds = resolveSearchTimeoutSeconds(searchConfig);
const cacheTtlMs = resolveSearchCacheTtlMs(searchConfig);
if (braveMode === "llm-context") {
const { results, sources } = await runBraveLlmContextSearch({
baseUrl: braveBaseUrl,
endpointMode: braveEndpointMode,
query,
apiKey,
timeoutSeconds,
diagnostics,
country: country ?? undefined,
search_lang: normalizedLanguage.search_lang,
freshness,
dateAfter,
dateBefore,
});
const payload = {
query,
provider: "brave",
mode: "llm-context" as const,
count: results.length,
tookMs: Date.now() - start,
externalContent: {
untrusted: true,
source: "web_search",
provider: "brave",
wrapped: true,
},
results: results.map((entry) => ({
title: entry.title ? wrapWebContent(entry.title, "web_search") : "",
url: entry.url,
snippets: entry.snippets.map((snippet) => wrapWebContent(snippet, "web_search")),
siteName: entry.siteName,
})),
sources,
};
writeCachedSearchPayload(cacheKey, payload, cacheTtlMs);
logBraveHttp(diagnostics, "cache write", {
mode: "llm-context",
query,
cacheKey,
ttlMs: cacheTtlMs,
count: results.length,
});
return payload;
}
const results = await runBraveWebSearch({
baseUrl: braveBaseUrl,
endpointMode: braveEndpointMode,
query,
count: resolveSearchCount(count, DEFAULT_SEARCH_COUNT),
apiKey,
timeoutSeconds,
diagnostics,
country: country ?? undefined,
search_lang: normalizedLanguage.search_lang,
ui_lang: normalizedLanguage.ui_lang,
freshness,
dateAfter,
dateBefore,
});
const payload = {
query,
provider: "brave",
count: results.length,
tookMs: Date.now() - start,
externalContent: {
untrusted: true,
source: "web_search",
provider: "brave",
wrapped: true,
},
results,
};
writeCachedSearchPayload(cacheKey, payload, cacheTtlMs);
logBraveHttp(diagnostics, "cache write", {
mode: "web",
query,
cacheKey,
ttlMs: cacheTtlMs,
count: results.length,
});
return payload;
}

View File

@@ -0,0 +1,237 @@
/**
* Brave Search request normalization and result mapping. It validates Brave
* country/language params and converts LLM-context responses into web results.
*/
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "openclaw/plugin-sdk/string-coerce-runtime";
type BraveConfig = {
baseUrl?: unknown;
mode?: string;
};
type BraveLlmContextResult = { url: string; title: string; snippets: string[] };
/** Brave LLM Context API response subset used by OpenClaw. */
export type BraveLlmContextResponse = {
grounding: { generic?: BraveLlmContextResult[] };
sources?: { url?: string; hostname?: string; date?: string }[];
};
const BRAVE_COUNTRY_CODES = new Set([
"AR",
"AU",
"AT",
"BE",
"BR",
"CA",
"CL",
"DK",
"FI",
"FR",
"DE",
"GR",
"HK",
"IN",
"ID",
"IT",
"JP",
"KR",
"MY",
"MX",
"NL",
"NZ",
"NO",
"CN",
"PL",
"PT",
"PH",
"RU",
"SA",
"ZA",
"ES",
"SE",
"CH",
"TW",
"TR",
"GB",
"US",
"ALL",
]);
const BRAVE_SEARCH_LANG_CODES = new Set([
"ar",
"eu",
"bn",
"bg",
"ca",
"zh-hans",
"zh-hant",
"hr",
"cs",
"da",
"nl",
"en",
"en-gb",
"et",
"fi",
"fr",
"gl",
"de",
"el",
"gu",
"he",
"hi",
"hu",
"is",
"it",
"jp",
"kn",
"ko",
"lv",
"lt",
"ms",
"ml",
"mr",
"nb",
"pl",
"pt-br",
"pt-pt",
"pa",
"ro",
"ru",
"sr",
"sk",
"sl",
"es",
"sv",
"ta",
"te",
"th",
"tr",
"uk",
"vi",
]);
const BRAVE_SEARCH_LANG_ALIASES: Record<string, string> = {
ja: "jp",
zh: "zh-hans",
"zh-cn": "zh-hans",
"zh-hk": "zh-hant",
"zh-sg": "zh-hans",
"zh-tw": "zh-hant",
};
const BRAVE_UI_LANG_LOCALE = /^([a-z]{2})-([a-z]{2})$/i;
function normalizeBraveSearchLang(value: string | undefined): string | undefined {
if (!value) {
return undefined;
}
const trimmed = value.trim();
if (!trimmed) {
return undefined;
}
const lower = normalizeLowercaseStringOrEmpty(trimmed);
const canonical = BRAVE_SEARCH_LANG_ALIASES[lower] ?? lower;
if (!BRAVE_SEARCH_LANG_CODES.has(canonical)) {
return undefined;
}
return canonical;
}
/** Normalize Brave country filter values. */
export function normalizeBraveCountry(value: string | undefined): string | undefined {
if (!value) {
return undefined;
}
const trimmed = value.trim();
if (!trimmed) {
return undefined;
}
const canonical = trimmed.toUpperCase();
return BRAVE_COUNTRY_CODES.has(canonical) ? canonical : "ALL";
}
function normalizeBraveUiLang(value: string | undefined): string | undefined {
if (!value) {
return undefined;
}
const trimmed = value.trim();
if (!trimmed) {
return undefined;
}
const match = trimmed.match(BRAVE_UI_LANG_LOCALE);
if (!match) {
return undefined;
}
const [, language, region] = match;
return `${normalizeLowercaseStringOrEmpty(language)}-${region.toUpperCase()}`;
}
/** Resolve Brave-specific web-search config from scoped search config. */
export function resolveBraveConfig(searchConfig?: Record<string, unknown>): BraveConfig {
const brave = searchConfig?.brave;
return brave && typeof brave === "object" && !Array.isArray(brave) ? (brave as BraveConfig) : {};
}
/** Resolve whether Brave should use web search or LLM Context API mode. */
export function resolveBraveMode(brave?: BraveConfig): "web" | "llm-context" {
return brave?.mode === "llm-context" ? "llm-context" : "web";
}
/** Normalize Brave search and UI language params, detecting swapped fields. */
export function normalizeBraveLanguageParams(params: { search_lang?: string; ui_lang?: string }): {
search_lang?: string;
ui_lang?: string;
invalidField?: "search_lang" | "ui_lang";
} {
const rawSearchLang = normalizeOptionalString(params.search_lang);
const rawUiLang = normalizeOptionalString(params.ui_lang);
let searchLangCandidate = rawSearchLang;
let uiLangCandidate = rawUiLang;
if (normalizeBraveUiLang(rawSearchLang) && normalizeBraveSearchLang(rawUiLang)) {
searchLangCandidate = rawUiLang;
uiLangCandidate = rawSearchLang;
}
const search_lang = normalizeBraveSearchLang(searchLangCandidate);
if (searchLangCandidate && !search_lang) {
return { invalidField: "search_lang" };
}
const ui_lang = normalizeBraveUiLang(uiLangCandidate);
if (uiLangCandidate && !ui_lang) {
return { invalidField: "ui_lang" };
}
return { search_lang, ui_lang };
}
function resolveSiteName(url: string | undefined): string | undefined {
if (!url) {
return undefined;
}
try {
return new URL(url).hostname;
} catch {
return undefined;
}
}
/** Map Brave LLM Context API grounding results into web-search result rows. */
export function mapBraveLlmContextResults(
data: BraveLlmContextResponse,
): { url: string; title: string; snippets: string[]; siteName?: string }[] {
const genericResults = Array.isArray(data.grounding?.generic) ? data.grounding.generic : [];
return genericResults.map((entry) => ({
url: entry.url ?? "",
title: entry.title ?? "",
snippets: (entry.snippets ?? []).filter(
(snippet) => typeof snippet === "string" && snippet.length > 0,
),
siteName: resolveSiteName(entry.url) || undefined,
}));
}

View File

@@ -0,0 +1,824 @@
// Brave tests cover brave web search provider plugin behavior.
import fs from "node:fs";
import { validateJsonSchemaValue } from "openclaw/plugin-sdk/json-schema-runtime";
import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
import { testing } from "../test-api.js";
import { createBraveWebSearchProvider as createBraveWebSearchContractProvider } from "../web-search-contract-api.js";
import { createBraveWebSearchProvider } from "./brave-web-search-provider.js";
const loggerInfoMock = vi.hoisted(() => vi.fn());
vi.mock("openclaw/plugin-sdk/runtime-env", () => ({
createSubsystemLogger: () => ({
info: loggerInfoMock,
debug: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
fatal: vi.fn(),
trace: vi.fn(),
raw: vi.fn(),
isEnabled: () => true,
child: () => ({
info: loggerInfoMock,
debug: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
fatal: vi.fn(),
trace: vi.fn(),
raw: vi.fn(),
isEnabled: () => true,
child: vi.fn(),
}),
}),
}));
const braveManifest = JSON.parse(
fs.readFileSync(new URL("../openclaw.plugin.json", import.meta.url), "utf-8"),
) as {
configSchema?: Record<string, unknown>;
};
afterAll(() => {
vi.doUnmock("openclaw/plugin-sdk/runtime-env");
vi.resetModules();
});
function jsonResponse(payload: unknown, init?: ResponseInit): Response {
return new Response(JSON.stringify(payload), {
status: 200,
headers: { "content-type": "application/json" },
...init,
});
}
function malformedJsonResponse(): Response {
return new Response("{ nope", {
status: 200,
headers: { "content-type": "application/json" },
});
}
function emptyWebSearchResponse(): Response {
return jsonResponse({ web: { results: [] } });
}
function installBraveLlmContextFetch() {
const mockFetch = vi.fn(async (_input?: unknown, _init?: unknown) => {
return jsonResponse({
grounding: {
generic: [
{
url: "https://example.com/context",
title: "Context",
snippets: ["snippet"],
},
],
},
sources: [],
});
});
global.fetch = mockFetch as typeof global.fetch;
return mockFetch;
}
function readHeader(init: unknown, name: string): string | null {
const headers = (init as { headers?: HeadersInit } | undefined)?.headers;
if (!headers) {
return null;
}
return new Headers(headers).get(name);
}
function fetchCall(mockFetch: { mock: { calls: Array<Array<unknown>> } }, index = 0) {
const call = mockFetch.mock.calls[index];
if (!call) {
throw new Error(`Expected fetch call ${index + 1}`);
}
return call;
}
function fetchRequestUrl(mockFetch: { mock: { calls: Array<Array<unknown>> } }, index = 0) {
return new URL(String(fetchCall(mockFetch, index)[0]));
}
function fetchRequestInit(mockFetch: { mock: { calls: Array<Array<unknown>> } }, index = 0) {
return fetchCall(mockFetch, index)[1];
}
function createBodyOnlyErrorResponse(params: { body: string; status: number }): Response {
const bytes = new TextEncoder().encode(params.body);
const body = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(bytes);
controller.close();
},
});
return {
ok: false,
status: params.status,
statusText: "Too Many Requests",
headers: new Headers(),
body,
} as Response;
}
describe("brave web search provider", () => {
const priorFetch = global.fetch;
afterEach(() => {
vi.unstubAllEnvs();
loggerInfoMock.mockClear();
global.fetch = priorFetch;
});
it("points provider metadata at the canonical Brave docs page", () => {
expect(createBraveWebSearchProvider().docsUrl).toBe(
"https://docs.openclaw.ai/tools/brave-search",
);
expect(createBraveWebSearchContractProvider().docsUrl).toBe(
"https://docs.openclaw.ai/tools/brave-search",
);
});
it("exposes legacy top-level apiKey as a Brave-owned compatibility fallback", () => {
const apiKey = { source: "env", provider: "default", id: "BRAVE_API_KEY" } as const;
const config = {
tools: {
web: {
search: {
apiKey,
},
},
},
};
expect(createBraveWebSearchProvider().getConfiguredCredentialValue?.(config)).toEqual(apiKey);
expect(createBraveWebSearchContractProvider().getConfiguredCredentialValue?.(config)).toEqual(
apiKey,
);
expect(createBraveWebSearchProvider().getConfiguredCredentialFallback?.(config)).toEqual({
path: "tools.web.search.apiKey",
value: apiKey,
});
expect(
createBraveWebSearchContractProvider().getConfiguredCredentialFallback?.(config),
).toEqual({
path: "tools.web.search.apiKey",
value: apiKey,
});
});
it("points missing-key users to fetch/browser alternatives", async () => {
vi.stubEnv("BRAVE_API_KEY", "");
const provider = createBraveWebSearchProvider();
const tool = provider.createTool({ config: {}, searchConfig: {} });
if (!tool) {
throw new Error("Expected tool definition");
}
const result = await tool.execute({ query: "OpenClaw docs" });
expect(result).toEqual({
error: "missing_brave_api_key",
message:
"web_search (brave) needs a Brave Search API key. Run `openclaw configure --section web` to store it, or set BRAVE_API_KEY in the Gateway environment. If you do not want to configure a search API key, use web_fetch for a specific URL or the browser tool for interactive pages.",
docs: "https://docs.openclaw.ai/tools/web",
});
});
it("normalizes brave language parameters and swaps reversed ui/search inputs", () => {
expect(
testing.normalizeBraveLanguageParams({
search_lang: "en-US",
ui_lang: "ja",
}),
).toEqual({
search_lang: "jp",
ui_lang: "en-US",
});
expect(testing.normalizeBraveLanguageParams({ search_lang: "tr-TR", ui_lang: "tr" })).toEqual({
search_lang: "tr",
ui_lang: "tr-TR",
});
expect(testing.normalizeBraveLanguageParams({ search_lang: "EN", ui_lang: "en-us" })).toEqual({
search_lang: "en",
ui_lang: "en-US",
});
});
it("flags invalid brave language fields", () => {
expect(
testing.normalizeBraveLanguageParams({
search_lang: "xx",
}),
).toEqual({ invalidField: "search_lang" });
expect(testing.normalizeBraveLanguageParams({ search_lang: "en-US" })).toEqual({
invalidField: "search_lang",
});
expect(testing.normalizeBraveLanguageParams({ ui_lang: "en" })).toEqual({
invalidField: "ui_lang",
});
});
it("normalizes Brave country codes and falls back unsupported values to ALL", () => {
expect(testing.normalizeBraveCountry("de")).toBe("DE");
expect(testing.normalizeBraveCountry(" VN ")).toBe("ALL");
expect(testing.normalizeBraveCountry("")).toBeUndefined();
});
it("defaults brave mode to web unless llm-context is explicitly selected", () => {
expect(testing.resolveBraveMode()).toBe("web");
expect(testing.resolveBraveMode({ mode: "llm-context" })).toBe("llm-context");
});
it("accepts llm-context in the Brave plugin config schema", () => {
if (!braveManifest.configSchema) {
throw new Error("Expected Brave manifest config schema");
}
const result = validateJsonSchemaValue({
schema: braveManifest.configSchema,
cacheKey: "test:brave-config-schema",
value: {
webSearch: {
mode: "llm-context",
},
},
});
expect(result.ok).toBe(true);
});
it("accepts baseUrl in the Brave plugin config schema", () => {
if (!braveManifest.configSchema) {
throw new Error("Expected Brave manifest config schema");
}
const result = validateJsonSchemaValue({
schema: braveManifest.configSchema,
cacheKey: "test:brave-config-schema-base-url",
value: {
webSearch: {
baseUrl: "https://api.search.brave.com/proxy",
},
},
});
expect(result.ok).toBe(true);
});
it("uses configured Brave baseUrl for web search requests", async () => {
vi.stubEnv("BRAVE_API_KEY", "");
const mockFetch = vi.fn(async (_input?: unknown, _init?: unknown) => {
return emptyWebSearchResponse();
});
global.fetch = mockFetch as typeof global.fetch;
const provider = createBraveWebSearchProvider();
const tool = provider.createTool({
config: {},
searchConfig: {
apiKey: "brave-test-key",
brave: {
baseUrl: "https://api.search.brave.com/proxy/",
mode: "web",
},
},
});
if (!tool) {
throw new Error("Expected tool definition");
}
await tool.execute({ query: "latest ai news" });
const requestUrl = fetchRequestUrl(mockFetch);
expect(requestUrl.origin).toBe("https://api.search.brave.com");
expect(requestUrl.pathname).toBe("/proxy/res/v1/web/search");
});
it("uses configured Brave baseUrl for llm-context requests", async () => {
vi.stubEnv("BRAVE_API_KEY", "");
const mockFetch = installBraveLlmContextFetch();
const provider = createBraveWebSearchProvider();
const tool = provider.createTool({
config: {},
searchConfig: {
apiKey: "brave-test-key",
brave: {
baseUrl: "https://api.search.brave.com/proxy",
mode: "llm-context",
},
},
});
if (!tool) {
throw new Error("Expected tool definition");
}
await tool.execute({ query: "latest ai news" });
const requestUrl = fetchRequestUrl(mockFetch);
expect(requestUrl.pathname).toBe("/proxy/res/v1/llm/context");
});
it("reports malformed Brave web search JSON as a provider error", async () => {
vi.stubEnv("BRAVE_API_KEY", "");
const mockFetch = vi.fn(async (_input?: unknown, _init?: unknown) => {
return malformedJsonResponse();
});
global.fetch = mockFetch as typeof global.fetch;
const provider = createBraveWebSearchProvider();
const tool = provider.createTool({
config: {},
searchConfig: {
apiKey: "brave-test-key",
brave: { mode: "web" },
},
});
if (!tool) {
throw new Error("Expected tool definition");
}
await expect(tool.execute({ query: "latest ai news" })).rejects.toThrow(
"Brave Search API error: malformed JSON response",
);
});
it("reports malformed Brave llm-context JSON as a provider error", async () => {
vi.stubEnv("BRAVE_API_KEY", "");
const mockFetch = vi.fn(async (_input?: unknown, _init?: unknown) => {
return malformedJsonResponse();
});
global.fetch = mockFetch as typeof global.fetch;
const provider = createBraveWebSearchProvider();
const tool = provider.createTool({
config: {},
searchConfig: {
apiKey: "brave-test-key",
brave: { mode: "llm-context" },
},
});
if (!tool) {
throw new Error("Expected tool definition");
}
await expect(tool.execute({ query: "latest ai news" })).rejects.toThrow(
"Brave LLM Context API error: malformed JSON response",
);
});
it("bounds Brave web error bodies without using response.text", async () => {
vi.stubEnv("BRAVE_API_KEY", "");
const mockFetch = vi.fn(async (_input?: unknown, _init?: unknown) =>
createBodyOnlyErrorResponse({
status: 429,
body: `${"x".repeat(24 * 1024)}tail-marker`,
}),
);
global.fetch = mockFetch as typeof global.fetch;
const provider = createBraveWebSearchProvider();
const tool = provider.createTool({
config: {},
searchConfig: {
apiKey: "brave-test-key",
brave: { mode: "web" },
},
});
if (!tool) {
throw new Error("Expected tool definition");
}
const error = await tool.execute({ query: "latest ai news" }).catch((value: unknown) => value);
expect(error).toBeInstanceOf(Error);
const message = error instanceof Error ? error.message : String(error);
expect(message).toContain("Brave Search API error (429):");
expect(message).not.toContain("tail-marker");
expect(message.length).toBeLessThan(700);
});
it("bounds Brave llm-context error bodies without using response.text", async () => {
vi.stubEnv("BRAVE_API_KEY", "");
const mockFetch = vi.fn(async (_input?: unknown, _init?: unknown) =>
createBodyOnlyErrorResponse({
status: 429,
body: `${"x".repeat(24 * 1024)}tail-marker`,
}),
);
global.fetch = mockFetch as typeof global.fetch;
const provider = createBraveWebSearchProvider();
const tool = provider.createTool({
config: {},
searchConfig: {
apiKey: "brave-test-key",
brave: { mode: "llm-context" },
},
});
if (!tool) {
throw new Error("Expected tool definition");
}
const error = await tool.execute({ query: "latest ai news" }).catch((value: unknown) => value);
expect(error).toBeInstanceOf(Error);
const message = error instanceof Error ? error.message : String(error);
expect(message).toContain("Brave LLM Context API error (429):");
expect(message).not.toContain("tail-marker");
expect(message.length).toBeLessThan(700);
});
it("keeps Brave cache entries isolated by baseUrl", async () => {
vi.stubEnv("BRAVE_API_KEY", "");
const mockFetch = vi.fn(async (_input?: unknown, _init?: unknown) => {
return emptyWebSearchResponse();
});
global.fetch = mockFetch as typeof global.fetch;
const provider = createBraveWebSearchProvider();
const firstTool = provider.createTool({
config: {},
searchConfig: {
apiKey: "brave-test-key",
brave: {
baseUrl: "https://api.search.brave.com/proxy-one",
mode: "web",
},
},
});
const secondTool = provider.createTool({
config: {},
searchConfig: {
apiKey: "brave-test-key",
brave: {
baseUrl: "https://api.search.brave.com/proxy-two",
mode: "web",
},
},
});
if (!firstTool || !secondTool) {
throw new Error("Expected tool definitions");
}
await firstTool.execute({ query: "base url cache identity" });
await secondTool.execute({ query: "base url cache identity" });
expect(mockFetch).toHaveBeenCalledTimes(2);
expect(fetchRequestUrl(mockFetch).pathname).toBe("/proxy-one/res/v1/web/search");
expect(fetchRequestUrl(mockFetch, 1).pathname).toBe("/proxy-two/res/v1/web/search");
});
it("rejects invalid Brave mode values in the plugin config schema", () => {
if (!braveManifest.configSchema) {
throw new Error("Expected Brave manifest config schema");
}
const result = validateJsonSchemaValue({
schema: braveManifest.configSchema,
cacheKey: "test:brave-config-schema",
value: {
webSearch: {
mode: "invalid-mode",
},
},
});
expect(result.ok).toBe(false);
if (result.ok) {
return;
}
expect(result.errors).toEqual([
{
path: "webSearch.mode",
message: 'must be equal to one of the allowed values (allowed: "web", "llm-context")',
text: 'webSearch.mode: must be equal to one of the allowed values (allowed: "web", "llm-context")',
allowedValues: ["web", "llm-context"],
allowedValuesHiddenCount: 0,
},
]);
});
it("maps llm-context results into wrapped source entries", () => {
expect(
testing.mapBraveLlmContextResults({
grounding: {
generic: [
{
url: "https://example.com/post",
title: "Example",
snippets: ["a", "", "b"],
},
],
},
}),
).toEqual([
{
url: "https://example.com/post",
title: "Example",
snippets: ["a", "b"],
siteName: "example.com",
},
]);
});
it("returns validation errors for invalid date ranges", async () => {
vi.stubEnv("BRAVE_API_KEY", "");
const provider = createBraveWebSearchProvider();
const tool = provider.createTool({
config: {},
searchConfig: {
apiKey: "BSA...",
brave: { apiKey: "BSA..." },
},
});
if (!tool) {
throw new Error("Expected tool definition");
}
const result = await tool.execute({
query: "latest gpu news",
date_after: "2026-03-20",
date_before: "2026-03-01",
});
expect(result).toEqual({
error: "invalid_date_range",
message: "date_after must be before date_before.",
docs: "https://docs.openclaw.ai/tools/web",
});
});
it("passes freshness to Brave llm-context endpoint", async () => {
vi.stubEnv("BRAVE_API_KEY", "test-key");
const mockFetch = installBraveLlmContextFetch();
const provider = createBraveWebSearchProvider();
const tool = provider.createTool({
config: {},
searchConfig: {
apiKey: "BSA...",
brave: { mode: "llm-context" },
},
});
if (!tool) {
throw new Error("Expected tool definition");
}
await tool.execute({ query: "latest ai news", freshness: "week" });
const requestUrl = fetchRequestUrl(mockFetch);
expect(requestUrl.pathname).toBe("/res/v1/llm/context");
expect(requestUrl.searchParams.get("freshness")).toBe("pw");
});
it("sends Brave web auth in the X-Subscription-Token header", async () => {
vi.stubEnv("BRAVE_API_KEY", "");
const mockFetch = vi.fn(async (_input?: unknown, _init?: unknown) => {
return emptyWebSearchResponse();
});
global.fetch = mockFetch as typeof global.fetch;
const provider = createBraveWebSearchProvider();
const tool = provider.createTool({
config: {},
searchConfig: {
apiKey: "brave-test-key",
brave: { mode: "web" },
},
});
if (!tool) {
throw new Error("Expected tool definition");
}
await tool.execute({ query: "latest ai news" });
const requestUrl = fetchRequestUrl(mockFetch);
expect(requestUrl.searchParams.get("apikey")).toBeNull();
expect(requestUrl.searchParams.get("key")).toBeNull();
expect(readHeader(fetchRequestInit(mockFetch), "X-Subscription-Token")).toBe("brave-test-key");
});
it("sends Brave llm-context auth in the X-Subscription-Token header", async () => {
vi.stubEnv("BRAVE_API_KEY", "");
const mockFetch = installBraveLlmContextFetch();
const provider = createBraveWebSearchProvider();
const tool = provider.createTool({
config: {},
searchConfig: {
apiKey: "brave-test-key",
brave: { mode: "llm-context" },
},
});
if (!tool) {
throw new Error("Expected tool definition");
}
await tool.execute({ query: "latest ai news" });
const requestUrl = fetchRequestUrl(mockFetch);
expect(requestUrl.searchParams.get("apikey")).toBeNull();
expect(requestUrl.searchParams.get("key")).toBeNull();
expect(readHeader(fetchRequestInit(mockFetch), "X-Subscription-Token")).toBe("brave-test-key");
});
it("passes bounded date ranges to Brave llm-context endpoint", async () => {
vi.stubEnv("BRAVE_API_KEY", "test-key");
const mockFetch = installBraveLlmContextFetch();
const provider = createBraveWebSearchProvider();
const tool = provider.createTool({
config: {},
searchConfig: {
apiKey: "BSA...",
brave: { mode: "llm-context" },
},
});
if (!tool) {
throw new Error("Expected tool definition");
}
await tool.execute({
query: "latest ai news",
date_after: "2025-01-01",
date_before: "2025-01-31",
});
const requestUrl = fetchRequestUrl(mockFetch);
expect(requestUrl.pathname).toBe("/res/v1/llm/context");
expect(requestUrl.searchParams.get("freshness")).toBe("2025-01-01to2025-01-31");
});
it("uses today as the end date for Brave llm-context date_after-only ranges", async () => {
vi.stubEnv("BRAVE_API_KEY", "test-key");
const mockFetch = installBraveLlmContextFetch();
const provider = createBraveWebSearchProvider();
const tool = provider.createTool({
config: {},
searchConfig: {
apiKey: "BSA...",
brave: { mode: "llm-context" },
},
});
if (!tool) {
throw new Error("Expected tool definition");
}
await tool.execute({ query: "latest ai news", date_after: "2025-01-01" });
const today = new Date().toISOString().slice(0, 10);
const requestUrl = fetchRequestUrl(mockFetch);
expect(requestUrl.pathname).toBe("/res/v1/llm/context");
expect(requestUrl.searchParams.get("freshness")).toBe(`2025-01-01to${today}`);
});
it("rejects future Brave llm-context date_after-only ranges before fetch", async () => {
vi.stubEnv("BRAVE_API_KEY", "test-key");
const mockFetch = installBraveLlmContextFetch();
const provider = createBraveWebSearchProvider();
const tool = provider.createTool({
config: {},
searchConfig: {
apiKey: "BSA...",
brave: { mode: "llm-context" },
},
});
if (!tool) {
throw new Error("Expected tool definition");
}
const result = await tool.execute({
query: "latest ai news",
date_after: "2999-01-01",
});
expect(result).toEqual({
error: "invalid_date_range",
message: "date_after cannot be in the future for Brave llm-context mode.",
docs: "https://docs.openclaw.ai/tools/web",
});
expect(mockFetch).not.toHaveBeenCalled();
});
it("rejects Brave llm-context date_before-only ranges before fetch", async () => {
vi.stubEnv("BRAVE_API_KEY", "test-key");
const mockFetch = installBraveLlmContextFetch();
const provider = createBraveWebSearchProvider();
const tool = provider.createTool({
config: {},
searchConfig: {
apiKey: "BSA...",
brave: { mode: "llm-context" },
},
});
if (!tool) {
throw new Error("Expected tool definition");
}
const result = await tool.execute({
query: "latest ai news",
date_before: "2025-01-31",
});
expect(result).toEqual({
error: "unsupported_date_filter",
message:
"Brave llm-context mode requires date_after when date_before is set. Use a bounded date range or freshness.",
docs: "https://docs.openclaw.ai/tools/web",
});
expect(mockFetch).not.toHaveBeenCalled();
});
it("falls back unsupported country values before calling Brave", async () => {
vi.stubEnv("BRAVE_API_KEY", "test-key");
const mockFetch = vi.fn(async (_input?: unknown, _init?: unknown) => {
return emptyWebSearchResponse();
});
global.fetch = mockFetch as typeof global.fetch;
const provider = createBraveWebSearchProvider();
const tool = provider.createTool({
config: {},
searchConfig: {
apiKey: "BSA...",
brave: { apiKey: "BSA..." },
},
});
if (!tool) {
throw new Error("Expected tool definition");
}
await tool.execute({
query: "latest Vietnam news",
country: "VN",
});
const requestUrl = fetchRequestUrl(mockFetch);
expect(requestUrl.searchParams.get("country")).toBe("ALL");
});
it("emits brave.http diagnostics for requests, responses, and cache events", async () => {
vi.stubEnv("BRAVE_API_KEY", "");
const mockFetch = vi.fn(async (_input?: unknown, _init?: unknown) => {
return jsonResponse({
web: {
results: [
{
title: "Diagnostics",
url: "https://example.com/diagnostics",
description: "debug details",
},
],
},
});
});
global.fetch = mockFetch as typeof global.fetch;
const provider = createBraveWebSearchProvider();
const tool = provider.createTool({
config: { diagnostics: { flags: ["brave.http"] } },
searchConfig: {
apiKey: "brave-test-key",
brave: { mode: "web" },
},
});
if (!tool) {
throw new Error("Expected tool definition");
}
await tool.execute({ query: "unique brave diagnostics query", count: 1 });
await tool.execute({ query: "unique brave diagnostics query", count: 1 });
expect(mockFetch).toHaveBeenCalledTimes(1);
const messages = loggerInfoMock.mock.calls.map((call) => call[0]);
expect(messages).toEqual([
"brave http cache miss",
"brave http request",
"brave http response",
"brave http cache write",
"brave http cache hit",
]);
const requestLog = loggerInfoMock.mock.calls.find(
([message]) => message === "brave http request",
);
expect(requestLog?.[1]).toEqual({
mode: "web",
query: "unique brave diagnostics query",
params: {
count: "1",
q: "unique brave diagnostics query",
},
url: "https://api.search.brave.com/res/v1/web/search?q=unique+brave+diagnostics+query&count=1",
});
const responseLog = loggerInfoMock.mock.calls.find(
([message]) => message === "brave http response",
);
const responsePayload = responseLog?.[1] as
| { durationMs?: unknown; mode?: unknown; ok?: unknown; status?: unknown }
| undefined;
expect(responsePayload?.mode).toBe("web");
expect(responsePayload?.status).toBe(200);
expect(responsePayload?.ok).toBe(true);
expect(typeof responsePayload?.durationMs).toBe("number");
expect(responsePayload?.durationMs).toBeGreaterThanOrEqual(0);
expect(JSON.stringify(loggerInfoMock.mock.calls)).not.toContain("brave-test-key");
expect(JSON.stringify(loggerInfoMock.mock.calls)).not.toContain("X-Subscription-Token");
});
});

View File

@@ -0,0 +1,107 @@
/**
* Brave web-search provider factory. It builds the agent tool definition and
* lazy-loads HTTP execution only when a search is run.
*/
import { isDiagnosticFlagEnabled } from "openclaw/plugin-sdk/diagnostic-runtime";
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
import type {
SearchConfigRecord,
WebSearchProviderPlugin,
WebSearchProviderToolDefinition,
} from "openclaw/plugin-sdk/provider-web-search";
import {
mergeScopedSearchConfig,
resolveProviderWebSearchPluginConfig,
} from "openclaw/plugin-sdk/provider-web-search-config-contract";
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
import { buildBraveWebSearchProviderBase } from "../web-search-shared.js";
const loadBraveWebSearchRuntime = createLazyRuntimeModule(
() => import("./brave-web-search-provider.runtime.js"),
);
const BraveSearchSchema = {
type: "object",
properties: {
query: { type: "string", description: "Search query string." },
count: {
type: "integer",
description: "Number of results to return (1-10).",
minimum: 1,
maximum: 10,
},
country: {
type: "string",
description:
"2-letter country code for region-specific results (e.g., 'DE', 'US', 'ALL'). Default: 'US'.",
},
language: {
type: "string",
description: "ISO 639-1 language code for results (e.g., 'en', 'de', 'fr').",
},
freshness: {
type: "string",
description: "Filter by time: 'day' (24h), 'week', 'month', or 'year'.",
},
date_after: {
type: "string",
description: "Only results published after this date (YYYY-MM-DD).",
},
date_before: {
type: "string",
description: "Only results published before this date (YYYY-MM-DD).",
},
search_lang: {
type: "string",
description:
"Brave language code for search results (e.g., 'en', 'de', 'en-gb', 'zh-hans', 'zh-hant', 'pt-br').",
},
ui_lang: {
type: "string",
description:
"Locale code for UI elements in language-region format (e.g., 'en-US', 'de-DE', 'fr-FR', 'tr-TR'). Must include region subtag.",
},
},
} satisfies Record<string, unknown>;
function resolveBraveMode(searchConfig?: Record<string, unknown>): "web" | "llm-context" {
const brave = isRecord(searchConfig?.brave) ? searchConfig.brave : undefined;
return brave?.mode === "llm-context" ? "llm-context" : "web";
}
function createBraveToolDefinition(
searchConfig?: SearchConfigRecord,
config?: Parameters<typeof isDiagnosticFlagEnabled>[1],
): WebSearchProviderToolDefinition {
const braveMode = resolveBraveMode(searchConfig);
const diagnosticsEnabled = isDiagnosticFlagEnabled("brave.http", config);
return {
description:
braveMode === "llm-context"
? "Search the web using Brave Search LLM Context API. Returns pre-extracted page content (text chunks, tables, code blocks) optimized for LLM grounding."
: "Search the web using Brave Search API. Supports region-specific and localized search via country and language parameters. Returns titles, URLs, and snippets for fast research.",
parameters: BraveSearchSchema,
execute: async (args) => {
const { executeBraveSearch } = await loadBraveWebSearchRuntime();
return await executeBraveSearch(args, searchConfig, { diagnosticsEnabled });
},
};
}
/** Create the runtime Brave Search provider descriptor. */
export function createBraveWebSearchProvider(): WebSearchProviderPlugin {
return {
...buildBraveWebSearchProviderBase(),
createTool: (ctx) =>
createBraveToolDefinition(
mergeScopedSearchConfig(
ctx.searchConfig,
"brave",
resolveProviderWebSearchPluginConfig(ctx.config, "brave"),
{ mirrorApiKeyToTopLevel: true },
),
ctx.config,
),
};
}

View File

@@ -0,0 +1,19 @@
/**
* Brave Search test API barrel. Tests import normalized helpers through this
* path instead of deep runtime modules.
*/
import {
mapBraveLlmContextResults,
normalizeBraveCountry,
normalizeBraveLanguageParams,
resolveBraveMode,
} from "./src/brave-web-search-provider.shared.js";
/** Test-only Brave normalization helpers. */
export const testing = {
normalizeBraveCountry,
normalizeBraveLanguageParams,
resolveBraveMode,
mapBraveLlmContextResults,
} as const;
export { testing as __testing };

View File

@@ -0,0 +1,16 @@
{
"extends": "../tsconfig.package-boundary.base.json",
"compilerOptions": {
"rootDir": "."
},
"include": ["./*.ts", "./src/**/*.ts"],
"exclude": [
"./**/*.test.ts",
"./dist/**",
"./node_modules/**",
"./src/test-support/**",
"./src/**/*test-helpers.ts",
"./src/**/*test-harness.ts",
"./src/**/*test-support.ts"
]
}

View File

@@ -0,0 +1,14 @@
/**
* Brave Search contract provider. It exposes provider metadata without creating
* the runtime search tool.
*/
import type { WebSearchProviderPlugin } from "openclaw/plugin-sdk/provider-web-search-config-contract";
import { buildBraveWebSearchProviderBase } from "./web-search-shared.js";
/** Create the Brave provider descriptor for contract checks. */
export function createBraveWebSearchProvider(): WebSearchProviderPlugin {
return {
...buildBraveWebSearchProviderBase(),
createTool: () => null,
};
}

View File

@@ -0,0 +1,5 @@
/**
* Public Brave web-search provider barrel. Runtime consumers import this
* lightweight path for the provider factory.
*/
export { createBraveWebSearchProvider } from "./src/brave-web-search-provider.js";

View File

@@ -0,0 +1,71 @@
/**
* Shared Brave Search provider metadata and credential lookup. Contract tests
* and runtime provider creation both use this lightweight descriptor.
*/
import {
createWebSearchProviderContractFields,
type WebSearchProviderPlugin,
} from "openclaw/plugin-sdk/provider-web-search-config-contract";
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
/** Canonical config path for the Brave Search API key. */
export const BRAVE_CREDENTIAL_PATH = "plugins.entries.brave.config.webSearch.apiKey";
/** Resolve legacy top-level Brave credentials from old web-search config. */
export function resolveLegacyTopLevelBraveCredential(
config: unknown,
): { path: string; value: unknown } | undefined {
if (!isRecord(config)) {
return undefined;
}
const tools = isRecord(config.tools) ? config.tools : undefined;
const web = isRecord(tools?.web) ? tools.web : undefined;
const search = isRecord(web?.search) ? web.search : undefined;
if (!search || !("apiKey" in search)) {
return undefined;
}
return { path: "tools.web.search.apiKey", value: search.apiKey };
}
function resolveBraveWebSearchPluginConfig(config: unknown): Record<string, unknown> | undefined {
if (!isRecord(config)) {
return undefined;
}
const plugins = isRecord(config.plugins) ? config.plugins : undefined;
const entries = isRecord(plugins?.entries) ? plugins.entries : undefined;
const entry = isRecord(entries?.brave) ? entries.brave : undefined;
const pluginConfig = isRecord(entry?.config) ? entry.config : undefined;
return isRecord(pluginConfig?.webSearch) ? pluginConfig.webSearch : undefined;
}
/** Resolve Brave credentials from current plugin config or legacy fallback. */
export function resolveConfiguredBraveCredential(config: unknown): unknown {
return (
resolveBraveWebSearchPluginConfig(config)?.apiKey ??
resolveLegacyTopLevelBraveCredential(config)?.value
);
}
/** Build the common Brave provider metadata without the runtime tool executor. */
export function buildBraveWebSearchProviderBase(): Omit<WebSearchProviderPlugin, "createTool"> {
return {
id: "brave",
label: "Brave Search",
hint: "Structured results · country/language/time filters",
onboardingScopes: ["text-inference"],
credentialLabel: "Brave Search API key",
envVars: ["BRAVE_API_KEY"],
placeholder: "BSA...",
signupUrl: "https://brave.com/search/api/",
docsUrl: "https://docs.openclaw.ai/tools/brave-search",
autoDetectOrder: 10,
credentialPath: BRAVE_CREDENTIAL_PATH,
...createWebSearchProviderContractFields({
credentialPath: BRAVE_CREDENTIAL_PATH,
searchCredential: { type: "top-level" },
configuredCredential: { pluginId: "brave" },
}),
getConfiguredCredentialValue: resolveConfiguredBraveCredential,
getConfiguredCredentialFallback: resolveLegacyTopLevelBraveCredential,
};
}