@openclaw/brave-plugin 2026.5.1-beta.1 → 2026.5.2-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -16,6 +16,10 @@
16
16
  "webSearch.mode": {
17
17
  "label": "Brave Search Mode",
18
18
  "help": "Brave Search mode: web or llm-context."
19
+ },
20
+ "webSearch.baseUrl": {
21
+ "label": "Brave Search Base URL",
22
+ "help": "Optional Brave-compatible API base URL for trusted proxies. Defaults to https://api.search.brave.com."
19
23
  }
20
24
  },
21
25
  "contracts": {
@@ -38,6 +42,9 @@
38
42
  "mode": {
39
43
  "type": "string",
40
44
  "enum": ["web", "llm-context"]
45
+ },
46
+ "baseUrl": {
47
+ "type": ["string", "object"]
41
48
  }
42
49
  }
43
50
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/brave-plugin",
3
- "version": "2026.5.1-beta.1",
3
+ "version": "2026.5.2-beta.1",
4
4
  "description": "OpenClaw Brave plugin",
5
5
  "repository": {
6
6
  "type": "git",
@@ -20,10 +20,10 @@
20
20
  "minHostVersion": ">=2026.4.10"
21
21
  },
22
22
  "compat": {
23
- "pluginApi": ">=2026.4.10"
23
+ "pluginApi": ">=2026.5.2-beta.1"
24
24
  },
25
25
  "build": {
26
- "openclawVersion": "2026.5.1-beta.1"
26
+ "openclawVersion": "2026.5.2-beta.1"
27
27
  },
28
28
  "release": {
29
29
  "publishToClawHub": true,
@@ -14,10 +14,18 @@ import {
14
14
  resolveSearchCount,
15
15
  resolveSearchTimeoutSeconds,
16
16
  resolveSiteName,
17
+ withSelfHostedWebSearchEndpoint,
17
18
  withTrustedWebSearchEndpoint,
18
19
  wrapWebContent,
19
20
  writeCachedSearchPayload,
20
21
  } from "openclaw/plugin-sdk/provider-web-search";
22
+ import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
23
+ import {
24
+ assertHttpUrlTargetsPrivateNetwork,
25
+ isBlockedHostnameOrIp,
26
+ isPrivateIpAddress,
27
+ resolvePinnedHostnameWithPolicy,
28
+ } from "openclaw/plugin-sdk/ssrf-runtime";
21
29
  import {
22
30
  type BraveLlmContextResponse,
23
31
  mapBraveLlmContextResults,
@@ -27,8 +35,11 @@ import {
27
35
  resolveBraveMode,
28
36
  } from "./brave-web-search-provider.shared.js";
29
37
 
30
- const BRAVE_SEARCH_ENDPOINT = "https://api.search.brave.com/res/v1/web/search";
31
- const BRAVE_LLM_CONTEXT_ENDPOINT = "https://api.search.brave.com/res/v1/llm/context";
38
+ const DEFAULT_BRAVE_BASE_URL = "https://api.search.brave.com";
39
+ const BRAVE_SEARCH_ENDPOINT_PATH = "/res/v1/web/search";
40
+ const BRAVE_LLM_CONTEXT_ENDPOINT_PATH = "/res/v1/llm/context";
41
+ const braveHttpLogger = createSubsystemLogger("brave/http");
42
+ type BraveEndpointMode = "selfHosted" | "strict";
32
43
 
33
44
  type BraveSearchResult = {
34
45
  title?: string;
@@ -43,6 +54,33 @@ type BraveSearchResponse = {
43
54
  };
44
55
  };
45
56
 
57
+ type BraveHttpDiagnostics = {
58
+ enabled?: boolean;
59
+ };
60
+
61
+ function logBraveHttp(
62
+ diagnostics: BraveHttpDiagnostics | undefined,
63
+ event: string,
64
+ meta?: Record<string, unknown>,
65
+ ): void {
66
+ if (!diagnostics?.enabled) {
67
+ return;
68
+ }
69
+ braveHttpLogger.info(`brave http ${event}`, meta);
70
+ }
71
+
72
+ function describeBraveRequestUrl(url: URL): {
73
+ url: string;
74
+ query: string;
75
+ params: Record<string, string>;
76
+ } {
77
+ return {
78
+ url: url.toString(),
79
+ query: url.searchParams.get("q") ?? "",
80
+ params: Object.fromEntries(url.searchParams.entries()),
81
+ };
82
+ }
83
+
46
84
  function resolveBraveApiKey(searchConfig?: SearchConfigRecord): string | undefined {
47
85
  return (
48
86
  readConfiguredSecretString(searchConfig?.apiKey, "tools.web.search.apiKey") ??
@@ -50,21 +88,83 @@ function resolveBraveApiKey(searchConfig?: SearchConfigRecord): string | undefin
50
88
  );
51
89
  }
52
90
 
91
+ function resolveBraveBaseUrl(braveConfig: { baseUrl?: unknown } | undefined): string {
92
+ const configured = readConfiguredSecretString(
93
+ braveConfig?.baseUrl,
94
+ "plugins.entries.brave.config.webSearch.baseUrl",
95
+ );
96
+ return configured?.replace(/\/+$/u, "") || DEFAULT_BRAVE_BASE_URL;
97
+ }
98
+
99
+ function buildBraveEndpointUrl(params: { baseUrl: string; endpointPath: string }): URL {
100
+ const url = new URL(params.baseUrl);
101
+ const basePath = url.pathname.replace(/\/+$/u, "");
102
+ url.pathname = `${basePath}${params.endpointPath}`;
103
+ url.search = "";
104
+ return url;
105
+ }
106
+
107
+ async function braveEndpointTargetsPrivateNetwork(url: URL): Promise<boolean> {
108
+ if (isBlockedHostnameOrIp(url.hostname)) {
109
+ return true;
110
+ }
111
+ try {
112
+ const pinned = await resolvePinnedHostnameWithPolicy(url.hostname, {
113
+ policy: {
114
+ allowPrivateNetwork: true,
115
+ allowRfc2544BenchmarkRange: true,
116
+ },
117
+ });
118
+ return pinned.addresses.every((address) => isPrivateIpAddress(address));
119
+ } catch {
120
+ return false;
121
+ }
122
+ }
123
+
124
+ async function validateBraveBaseUrl(baseUrl: string): Promise<BraveEndpointMode> {
125
+ let parsed: URL;
126
+ try {
127
+ parsed = new URL(baseUrl);
128
+ } catch {
129
+ throw new Error("Brave Search base URL must be a valid http:// or https:// URL.");
130
+ }
131
+
132
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
133
+ throw new Error("Brave Search base URL must use http:// or https://.");
134
+ }
135
+
136
+ if (parsed.protocol === "http:") {
137
+ await assertHttpUrlTargetsPrivateNetwork(parsed.toString(), {
138
+ dangerouslyAllowPrivateNetwork: true,
139
+ errorMessage:
140
+ "Brave Search HTTP base URL must target a trusted private or loopback host. Use https:// for public hosts.",
141
+ });
142
+ return "selfHosted";
143
+ }
144
+
145
+ return (await braveEndpointTargetsPrivateNetwork(parsed)) ? "selfHosted" : "strict";
146
+ }
147
+
53
148
  function missingBraveKeyPayload() {
54
149
  return {
55
150
  error: "missing_brave_api_key",
56
- 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.`,
151
+ 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.`,
57
152
  docs: "https://docs.openclaw.ai/tools/web",
58
153
  };
59
154
  }
60
155
 
61
156
  async function runBraveLlmContextSearch(params: {
157
+ baseUrl: string;
158
+ endpointMode: BraveEndpointMode;
62
159
  query: string;
63
160
  apiKey: string;
64
161
  timeoutSeconds: number;
162
+ diagnostics?: BraveHttpDiagnostics;
65
163
  country?: string;
66
164
  search_lang?: string;
67
165
  freshness?: string;
166
+ dateAfter?: string;
167
+ dateBefore?: string;
68
168
  }): Promise<{
69
169
  results: Array<{
70
170
  url: string;
@@ -74,7 +174,10 @@ async function runBraveLlmContextSearch(params: {
74
174
  }>;
75
175
  sources?: BraveLlmContextResponse["sources"];
76
176
  }> {
77
- const url = new URL(BRAVE_LLM_CONTEXT_ENDPOINT);
177
+ const url = buildBraveEndpointUrl({
178
+ baseUrl: params.baseUrl,
179
+ endpointPath: BRAVE_LLM_CONTEXT_ENDPOINT_PATH,
180
+ });
78
181
  url.searchParams.set("q", params.query);
79
182
  if (params.country) {
80
183
  url.searchParams.set("country", params.country);
@@ -84,9 +187,25 @@ async function runBraveLlmContextSearch(params: {
84
187
  }
85
188
  if (params.freshness) {
86
189
  url.searchParams.set("freshness", params.freshness);
190
+ } else if (params.dateAfter && params.dateBefore) {
191
+ url.searchParams.set("freshness", `${params.dateAfter}to${params.dateBefore}`);
192
+ } else if (params.dateAfter) {
193
+ url.searchParams.set(
194
+ "freshness",
195
+ `${params.dateAfter}to${new Date().toISOString().slice(0, 10)}`,
196
+ );
87
197
  }
88
198
 
89
- return withTrustedWebSearchEndpoint(
199
+ logBraveHttp(params.diagnostics, "request", {
200
+ mode: "llm-context",
201
+ ...describeBraveRequestUrl(url),
202
+ });
203
+ const startedAt = Date.now();
204
+ const withEndpoint =
205
+ params.endpointMode === "selfHosted"
206
+ ? withSelfHostedWebSearchEndpoint
207
+ : withTrustedWebSearchEndpoint;
208
+ return withEndpoint(
90
209
  {
91
210
  url: url.toString(),
92
211
  timeoutSeconds: params.timeoutSeconds,
@@ -99,6 +218,12 @@ async function runBraveLlmContextSearch(params: {
99
218
  },
100
219
  },
101
220
  async (response) => {
221
+ logBraveHttp(params.diagnostics, "response", {
222
+ mode: "llm-context",
223
+ status: response.status,
224
+ ok: response.ok,
225
+ durationMs: Date.now() - startedAt,
226
+ });
102
227
  if (!response.ok) {
103
228
  const detail = await response.text();
104
229
  throw new Error(
@@ -113,10 +238,13 @@ async function runBraveLlmContextSearch(params: {
113
238
  }
114
239
 
115
240
  async function runBraveWebSearch(params: {
241
+ baseUrl: string;
242
+ endpointMode: BraveEndpointMode;
116
243
  query: string;
117
244
  count: number;
118
245
  apiKey: string;
119
246
  timeoutSeconds: number;
247
+ diagnostics?: BraveHttpDiagnostics;
120
248
  country?: string;
121
249
  search_lang?: string;
122
250
  ui_lang?: string;
@@ -124,7 +252,10 @@ async function runBraveWebSearch(params: {
124
252
  dateAfter?: string;
125
253
  dateBefore?: string;
126
254
  }): Promise<Array<Record<string, unknown>>> {
127
- const url = new URL(BRAVE_SEARCH_ENDPOINT);
255
+ const url = buildBraveEndpointUrl({
256
+ baseUrl: params.baseUrl,
257
+ endpointPath: BRAVE_SEARCH_ENDPOINT_PATH,
258
+ });
128
259
  url.searchParams.set("q", params.query);
129
260
  url.searchParams.set("count", String(params.count));
130
261
  if (params.country) {
@@ -149,7 +280,16 @@ async function runBraveWebSearch(params: {
149
280
  url.searchParams.set("freshness", `1970-01-01to${params.dateBefore}`);
150
281
  }
151
282
 
152
- return withTrustedWebSearchEndpoint(
283
+ logBraveHttp(params.diagnostics, "request", {
284
+ mode: "web",
285
+ ...describeBraveRequestUrl(url),
286
+ });
287
+ const startedAt = Date.now();
288
+ const withEndpoint =
289
+ params.endpointMode === "selfHosted"
290
+ ? withSelfHostedWebSearchEndpoint
291
+ : withTrustedWebSearchEndpoint;
292
+ return withEndpoint(
153
293
  {
154
294
  url: url.toString(),
155
295
  timeoutSeconds: params.timeoutSeconds,
@@ -162,6 +302,12 @@ async function runBraveWebSearch(params: {
162
302
  },
163
303
  },
164
304
  async (response) => {
305
+ logBraveHttp(params.diagnostics, "response", {
306
+ mode: "web",
307
+ status: response.status,
308
+ ok: response.ok,
309
+ durationMs: Date.now() - startedAt,
310
+ });
165
311
  if (!response.ok) {
166
312
  const detail = await response.text();
167
313
  throw new Error(
@@ -190,6 +336,9 @@ async function runBraveWebSearch(params: {
190
336
  export async function executeBraveSearch(
191
337
  args: Record<string, unknown>,
192
338
  searchConfig?: SearchConfigRecord,
339
+ options?: {
340
+ diagnosticsEnabled?: boolean;
341
+ },
193
342
  ): Promise<Record<string, unknown>> {
194
343
  const apiKey = resolveBraveApiKey(searchConfig);
195
344
  if (!apiKey) {
@@ -198,6 +347,8 @@ export async function executeBraveSearch(
198
347
 
199
348
  const braveConfig = resolveBraveConfig(searchConfig);
200
349
  const braveMode = resolveBraveMode(braveConfig);
350
+ const braveBaseUrl = resolveBraveBaseUrl(braveConfig);
351
+ const braveEndpointMode = await validateBraveBaseUrl(braveBaseUrl);
201
352
  const query = readStringParam(args, "query", { required: true });
202
353
  const count =
203
354
  readNumberParam(args, "count", { integer: true }) ?? searchConfig?.maxResults ?? undefined;
@@ -235,14 +386,6 @@ export async function executeBraveSearch(
235
386
  }
236
387
 
237
388
  const rawFreshness = readStringParam(args, "freshness");
238
- if (rawFreshness && braveMode === "llm-context") {
239
- return {
240
- error: "unsupported_freshness",
241
- message:
242
- "freshness filtering is not supported by Brave llm-context mode. Remove freshness or use Brave web mode.",
243
- docs: "https://docs.openclaw.ai/tools/web",
244
- };
245
- }
246
389
  const freshness = rawFreshness ? normalizeFreshness(rawFreshness, "brave") : undefined;
247
390
  if (rawFreshness && !freshness) {
248
391
  return {
@@ -262,15 +405,6 @@ export async function executeBraveSearch(
262
405
  docs: "https://docs.openclaw.ai/tools/web",
263
406
  };
264
407
  }
265
- if ((rawDateAfter || rawDateBefore) && braveMode === "llm-context") {
266
- return {
267
- error: "unsupported_date_filter",
268
- message:
269
- "date_after/date_before filtering is not supported by Brave llm-context mode. Use Brave web mode for date filters.",
270
- docs: "https://docs.openclaw.ai/tools/web",
271
- };
272
- }
273
-
274
408
  const parsedDateRange = parseIsoDateRange({
275
409
  rawDateAfter,
276
410
  rawDateBefore,
@@ -283,22 +417,62 @@ export async function executeBraveSearch(
283
417
  }
284
418
 
285
419
  const { dateAfter, dateBefore } = parsedDateRange;
286
- const cacheKey = buildSearchCacheKey([
287
- "brave",
288
- braveMode,
289
- query,
290
- resolveSearchCount(count, DEFAULT_SEARCH_COUNT),
291
- country,
292
- normalizedLanguage.search_lang,
293
- normalizedLanguage.ui_lang,
294
- freshness,
295
- dateAfter,
296
- dateBefore,
297
- ]);
420
+ if (braveMode === "llm-context") {
421
+ const today = new Date().toISOString().slice(0, 10);
422
+ if (dateAfter && !dateBefore && dateAfter > today) {
423
+ return {
424
+ error: "invalid_date_range",
425
+ message: "date_after cannot be in the future for Brave llm-context mode.",
426
+ docs: "https://docs.openclaw.ai/tools/web",
427
+ };
428
+ }
429
+ if (dateBefore && !dateAfter) {
430
+ return {
431
+ error: "unsupported_date_filter",
432
+ message:
433
+ "Brave llm-context mode requires date_after when date_before is set. Use a bounded date range or freshness.",
434
+ docs: "https://docs.openclaw.ai/tools/web",
435
+ };
436
+ }
437
+ }
438
+ const llmContextDateEnd =
439
+ braveMode === "llm-context" && dateAfter
440
+ ? (dateBefore ?? new Date().toISOString().slice(0, 10))
441
+ : dateBefore;
442
+ const cacheKey = buildSearchCacheKey(
443
+ braveMode === "llm-context"
444
+ ? [
445
+ "brave",
446
+ braveMode,
447
+ braveBaseUrl,
448
+ query,
449
+ country,
450
+ normalizedLanguage.search_lang,
451
+ freshness,
452
+ dateAfter,
453
+ llmContextDateEnd,
454
+ ]
455
+ : [
456
+ "brave",
457
+ braveMode,
458
+ braveBaseUrl,
459
+ query,
460
+ resolveSearchCount(count, DEFAULT_SEARCH_COUNT),
461
+ country,
462
+ normalizedLanguage.search_lang,
463
+ normalizedLanguage.ui_lang,
464
+ freshness,
465
+ dateAfter,
466
+ dateBefore,
467
+ ],
468
+ );
469
+ const diagnostics: BraveHttpDiagnostics = { enabled: options?.diagnosticsEnabled === true };
298
470
  const cached = readCachedSearchPayload(cacheKey);
299
471
  if (cached) {
472
+ logBraveHttp(diagnostics, "cache hit", { mode: braveMode, query, cacheKey });
300
473
  return cached;
301
474
  }
475
+ logBraveHttp(diagnostics, "cache miss", { mode: braveMode, query, cacheKey });
302
476
 
303
477
  const start = Date.now();
304
478
  const timeoutSeconds = resolveSearchTimeoutSeconds(searchConfig);
@@ -306,12 +480,17 @@ export async function executeBraveSearch(
306
480
 
307
481
  if (braveMode === "llm-context") {
308
482
  const { results, sources } = await runBraveLlmContextSearch({
483
+ baseUrl: braveBaseUrl,
484
+ endpointMode: braveEndpointMode,
309
485
  query,
310
486
  apiKey,
311
487
  timeoutSeconds,
488
+ diagnostics,
312
489
  country: country ?? undefined,
313
490
  search_lang: normalizedLanguage.search_lang,
314
491
  freshness,
492
+ dateAfter,
493
+ dateBefore,
315
494
  });
316
495
  const payload = {
317
496
  query,
@@ -334,14 +513,24 @@ export async function executeBraveSearch(
334
513
  sources,
335
514
  };
336
515
  writeCachedSearchPayload(cacheKey, payload, cacheTtlMs);
516
+ logBraveHttp(diagnostics, "cache write", {
517
+ mode: "llm-context",
518
+ query,
519
+ cacheKey,
520
+ ttlMs: cacheTtlMs,
521
+ count: results.length,
522
+ });
337
523
  return payload;
338
524
  }
339
525
 
340
526
  const results = await runBraveWebSearch({
527
+ baseUrl: braveBaseUrl,
528
+ endpointMode: braveEndpointMode,
341
529
  query,
342
530
  count: resolveSearchCount(count, DEFAULT_SEARCH_COUNT),
343
531
  apiKey,
344
532
  timeoutSeconds,
533
+ diagnostics,
345
534
  country: country ?? undefined,
346
535
  search_lang: normalizedLanguage.search_lang,
347
536
  ui_lang: normalizedLanguage.ui_lang,
@@ -363,5 +552,12 @@ export async function executeBraveSearch(
363
552
  results,
364
553
  };
365
554
  writeCachedSearchPayload(cacheKey, payload, cacheTtlMs);
555
+ logBraveHttp(diagnostics, "cache write", {
556
+ mode: "web",
557
+ query,
558
+ cacheKey,
559
+ ttlMs: cacheTtlMs,
560
+ count: results.length,
561
+ });
366
562
  return payload;
367
563
  }
@@ -4,6 +4,7 @@ import {
4
4
  } from "openclaw/plugin-sdk/text-runtime";
5
5
 
6
6
  type BraveConfig = {
7
+ baseUrl?: unknown;
7
8
  mode?: string;
8
9
  };
9
10