mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
feat: add Brave Web Search WASM tool (#474)
* feat: add Brave Web Search WASM tool Add a new WASM tool for searching the web via the Brave Search API. Follows the same architecture as the GitHub WASM tool with zero-exposure credential injection (X-Subscription-Token header). Features: - Full Brave Search API support (query, count, country, search_lang, ui_lang, freshness) - Input validation on all parameters - Retry logic for 429/5xx transient errors - RFC 3986 percent-encoding - Registry manifest for Extensions tab discovery Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: avoid Vec allocation in is_valid_ui_lang Use iterator-based destructuring instead of collecting into a Vec, avoiding a heap allocation in the WASM sandbox. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> --------- Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
20073ccf57
commit
5257fecca1
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "web-search",
|
||||
"display_name": "Web Search",
|
||||
"kind": "tool",
|
||||
"version": "0.1.0",
|
||||
"description": "Search the web using Brave Search API",
|
||||
"keywords": ["search", "web", "brave", "internet"],
|
||||
|
||||
"source": {
|
||||
"dir": "tools-src/web-search",
|
||||
"capabilities": "web-search-tool.capabilities.json",
|
||||
"crate_name": "web-search-tool"
|
||||
},
|
||||
|
||||
"artifacts": {
|
||||
"wasm32-wasip2": {
|
||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/web-search-wasm32-wasip2.tar.gz",
|
||||
"sha256": null
|
||||
}
|
||||
},
|
||||
|
||||
"auth_summary": {
|
||||
"method": "manual",
|
||||
"provider": "Brave",
|
||||
"secrets": ["brave_api_key"],
|
||||
"shared_auth": null,
|
||||
"setup_url": "https://brave.com/search/api/"
|
||||
},
|
||||
|
||||
"tags": ["default", "search"]
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
[package]
|
||||
name = "web-search-tool"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Brave Web Search tool for IronClaw (WASM component)"
|
||||
license = "MIT OR Apache-2.0"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
wit-bindgen = "0.41.0"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[profile.release]
|
||||
opt-level = "s"
|
||||
lto = true
|
||||
strip = true
|
||||
codegen-units = 1
|
||||
|
||||
|
||||
[workspace]
|
||||
@@ -0,0 +1,478 @@
|
||||
//! Brave Web Search WASM Tool for IronClaw.
|
||||
//!
|
||||
//! Searches the web using the Brave Search API and returns structured results.
|
||||
//!
|
||||
//! # Authentication
|
||||
//!
|
||||
//! Store your Brave Search API key:
|
||||
//! `ironclaw secret set brave_api_key <key>`
|
||||
//!
|
||||
//! Get a key at: https://brave.com/search/api/
|
||||
|
||||
wit_bindgen::generate!({
|
||||
world: "sandboxed-tool",
|
||||
path: "../../wit/tool.wit",
|
||||
});
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
const BRAVE_SEARCH_ENDPOINT: &str = "https://api.search.brave.com/res/v1/web/search";
|
||||
const MAX_COUNT: u32 = 20;
|
||||
const DEFAULT_COUNT: u32 = 5;
|
||||
const MAX_RETRIES: u32 = 3;
|
||||
|
||||
struct WebSearchTool;
|
||||
|
||||
impl exports::near::agent::tool::Guest for WebSearchTool {
|
||||
fn execute(req: exports::near::agent::tool::Request) -> exports::near::agent::tool::Response {
|
||||
match execute_inner(&req.params) {
|
||||
Ok(result) => exports::near::agent::tool::Response {
|
||||
output: Some(result),
|
||||
error: None,
|
||||
},
|
||||
Err(e) => exports::near::agent::tool::Response {
|
||||
output: None,
|
||||
error: Some(e),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn schema() -> String {
|
||||
SCHEMA.to_string()
|
||||
}
|
||||
|
||||
fn description() -> String {
|
||||
"Search the web using Brave Search. Returns titles, URLs, descriptions, and \
|
||||
publication dates for matching web pages. Supports filtering by country, \
|
||||
language, and freshness. Authentication is handled via the 'brave_api_key' \
|
||||
secret injected by the host."
|
||||
.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SearchParams {
|
||||
query: String,
|
||||
count: Option<u32>,
|
||||
country: Option<String>,
|
||||
search_lang: Option<String>,
|
||||
ui_lang: Option<String>,
|
||||
freshness: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct BraveSearchResponse {
|
||||
web: Option<BraveWebResults>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct BraveWebResults {
|
||||
results: Option<Vec<BraveSearchResult>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct BraveSearchResult {
|
||||
title: Option<String>,
|
||||
url: Option<String>,
|
||||
description: Option<String>,
|
||||
age: Option<String>,
|
||||
}
|
||||
|
||||
fn execute_inner(params: &str) -> Result<String, String> {
|
||||
let params: SearchParams =
|
||||
serde_json::from_str(params).map_err(|e| format!("Invalid parameters: {e}"))?;
|
||||
|
||||
if params.query.is_empty() {
|
||||
return Err("'query' must not be empty".into());
|
||||
}
|
||||
if params.query.len() > 2000 {
|
||||
return Err("'query' exceeds maximum length of 2000 characters".into());
|
||||
}
|
||||
|
||||
// Validate optional parameters.
|
||||
if let Some(ref lang) = params.search_lang {
|
||||
if !is_valid_lang_code(lang) {
|
||||
return Err(format!(
|
||||
"Invalid 'search_lang': expected 2-letter code like 'en', got '{lang}'"
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(ref country) = params.country {
|
||||
if !is_valid_country_code(country) {
|
||||
return Err(format!(
|
||||
"Invalid 'country': expected 2-letter code like 'US', got '{country}'"
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(ref ui_lang) = params.ui_lang {
|
||||
if !is_valid_ui_lang(ui_lang) {
|
||||
return Err(format!(
|
||||
"Invalid 'ui_lang': expected format like 'en-US', got '{ui_lang}'"
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(ref freshness) = params.freshness {
|
||||
if !is_valid_freshness(freshness) {
|
||||
return Err(format!(
|
||||
"Invalid 'freshness': expected 'pd', 'pw', 'pm', 'py', or \
|
||||
'YYYY-MM-DDtoYYYY-MM-DD', got '{freshness}'"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-flight: verify API key is available.
|
||||
if !near::agent::host::secret_exists("brave_api_key") {
|
||||
return Err(
|
||||
"Brave API key not found in secret store. Set it with: \
|
||||
ironclaw secret set brave_api_key <key>. \
|
||||
Get a key at: https://brave.com/search/api/"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
let count = params.count.unwrap_or(DEFAULT_COUNT).clamp(1, MAX_COUNT);
|
||||
let url = build_search_url(¶ms.query, count, ¶ms);
|
||||
|
||||
// X-Subscription-Token is injected by the host via credential config.
|
||||
let headers = serde_json::json!({
|
||||
"Accept": "application/json",
|
||||
"User-Agent": "IronClaw-WebSearch-Tool/0.1"
|
||||
});
|
||||
|
||||
// Retry loop for transient errors (429 rate limit, 5xx server errors).
|
||||
let response = {
|
||||
let mut attempt = 0;
|
||||
loop {
|
||||
attempt += 1;
|
||||
|
||||
let resp =
|
||||
near::agent::host::http_request("GET", &url, &headers.to_string(), None, None)
|
||||
.map_err(|e| format!("HTTP request failed: {e}"))?;
|
||||
|
||||
if resp.status >= 200 && resp.status < 300 {
|
||||
break resp;
|
||||
}
|
||||
|
||||
if attempt < MAX_RETRIES && (resp.status == 429 || resp.status >= 500) {
|
||||
near::agent::host::log(
|
||||
near::agent::host::LogLevel::Warn,
|
||||
&format!(
|
||||
"Brave API error {} (attempt {}/{}). Retrying...",
|
||||
resp.status, attempt, MAX_RETRIES
|
||||
),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
let body = String::from_utf8_lossy(&resp.body);
|
||||
return Err(format!(
|
||||
"Brave API error (HTTP {}): {}",
|
||||
resp.status, body
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let body =
|
||||
String::from_utf8(response.body).map_err(|e| format!("Invalid UTF-8 response: {e}"))?;
|
||||
|
||||
let brave_response: BraveSearchResponse =
|
||||
serde_json::from_str(&body).map_err(|e| format!("Failed to parse Brave response: {e}"))?;
|
||||
|
||||
let results = brave_response
|
||||
.web
|
||||
.and_then(|w| w.results)
|
||||
.unwrap_or_default();
|
||||
|
||||
let formatted: Vec<serde_json::Value> = results
|
||||
.into_iter()
|
||||
.filter_map(|r| {
|
||||
let title = r.title?;
|
||||
let url = r.url?;
|
||||
let description = r.description.unwrap_or_default();
|
||||
|
||||
let mut entry = serde_json::json!({
|
||||
"title": title,
|
||||
"url": url,
|
||||
"description": description,
|
||||
});
|
||||
if let Some(age) = r.age {
|
||||
entry["published"] = serde_json::json!(age);
|
||||
}
|
||||
// Extract hostname for site_name.
|
||||
if let Some(host) = extract_hostname(&url) {
|
||||
entry["site_name"] = serde_json::json!(host);
|
||||
}
|
||||
Some(entry)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let output = serde_json::json!({
|
||||
"query": params.query,
|
||||
"result_count": formatted.len(),
|
||||
"results": formatted,
|
||||
});
|
||||
|
||||
serde_json::to_string(&output).map_err(|e| format!("Failed to serialize output: {e}"))
|
||||
}
|
||||
|
||||
fn build_search_url(query: &str, count: u32, params: &SearchParams) -> String {
|
||||
let mut url = format!(
|
||||
"{}?q={}&count={}",
|
||||
BRAVE_SEARCH_ENDPOINT,
|
||||
url_encode(query),
|
||||
count
|
||||
);
|
||||
|
||||
if let Some(ref country) = params.country {
|
||||
url.push_str(&format!("&country={}", url_encode(country)));
|
||||
}
|
||||
if let Some(ref search_lang) = params.search_lang {
|
||||
url.push_str(&format!("&search_lang={}", url_encode(search_lang)));
|
||||
}
|
||||
if let Some(ref ui_lang) = params.ui_lang {
|
||||
url.push_str(&format!("&ui_lang={}", url_encode(ui_lang)));
|
||||
}
|
||||
if let Some(ref freshness) = params.freshness {
|
||||
url.push_str(&format!("&freshness={}", url_encode(freshness)));
|
||||
}
|
||||
|
||||
url
|
||||
}
|
||||
|
||||
/// Percent-encode a string for safe use in URL query parameters.
|
||||
fn url_encode(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len() * 2);
|
||||
for b in s.bytes() {
|
||||
match b {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
||||
out.push(b as char);
|
||||
}
|
||||
b' ' => out.push_str("%20"),
|
||||
_ => {
|
||||
out.push('%');
|
||||
out.push(char::from(b"0123456789ABCDEF"[(b >> 4) as usize]));
|
||||
out.push(char::from(b"0123456789ABCDEF"[(b & 0xf) as usize]));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Extract hostname from a URL string without a URL parser.
|
||||
fn extract_hostname(url: &str) -> Option<String> {
|
||||
let after_scheme = url
|
||||
.strip_prefix("https://")
|
||||
.or_else(|| url.strip_prefix("http://"))?;
|
||||
let host = after_scheme.split('/').next()?;
|
||||
let host = host.split(':').next()?; // strip port
|
||||
if host.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(host.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate a 2-letter language code (e.g. "en", "de").
|
||||
fn is_valid_lang_code(s: &str) -> bool {
|
||||
s.len() == 2 && s.bytes().all(|b| b.is_ascii_lowercase())
|
||||
}
|
||||
|
||||
/// Validate a 2-letter country code (e.g. "US", "DE").
|
||||
fn is_valid_country_code(s: &str) -> bool {
|
||||
s.len() == 2 && s.bytes().all(|b| b.is_ascii_uppercase())
|
||||
}
|
||||
|
||||
/// Validate a UI locale string (e.g. "en-US").
|
||||
fn is_valid_ui_lang(s: &str) -> bool {
|
||||
let mut parts = s.split('-');
|
||||
if let (Some(lang), Some(country), None) = (parts.next(), parts.next(), parts.next()) {
|
||||
is_valid_lang_code(lang) && is_valid_country_code(country)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate a freshness filter value.
|
||||
fn is_valid_freshness(s: &str) -> bool {
|
||||
matches!(s, "pd" | "pw" | "pm" | "py") || is_valid_date_range(s)
|
||||
}
|
||||
|
||||
/// Check if the string is a valid date range like "2024-01-01to2024-12-31".
|
||||
fn is_valid_date_range(s: &str) -> bool {
|
||||
if let Some((start, end)) = s.split_once("to") {
|
||||
is_date_like(start) && is_date_like(end)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Basic check for YYYY-MM-DD format.
|
||||
fn is_date_like(s: &str) -> bool {
|
||||
s.len() == 10
|
||||
&& s.as_bytes().get(4) == Some(&b'-')
|
||||
&& s.as_bytes().get(7) == Some(&b'-')
|
||||
&& s.bytes()
|
||||
.enumerate()
|
||||
.all(|(i, b)| i == 4 || i == 7 || b.is_ascii_digit())
|
||||
}
|
||||
|
||||
const SCHEMA: &str = r#"{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "The search query to look up on the web"
|
||||
},
|
||||
"count": {
|
||||
"type": "integer",
|
||||
"description": "Number of results to return (1-20, default 5)",
|
||||
"minimum": 1,
|
||||
"maximum": 20,
|
||||
"default": 5
|
||||
},
|
||||
"country": {
|
||||
"type": "string",
|
||||
"description": "2-letter uppercase country code to bias results (e.g. 'US', 'DE', 'JP')"
|
||||
},
|
||||
"search_lang": {
|
||||
"type": "string",
|
||||
"description": "2-letter lowercase language code for search results (e.g. 'en', 'de', 'fr')"
|
||||
},
|
||||
"ui_lang": {
|
||||
"type": "string",
|
||||
"description": "Locale in language-region format (e.g. 'en-US', 'de-DE')"
|
||||
},
|
||||
"freshness": {
|
||||
"type": "string",
|
||||
"description": "Filter by discovery time: 'pd' (past day), 'pw' (past week), 'pm' (past month), 'py' (past year), or date range 'YYYY-MM-DDtoYYYY-MM-DD'"
|
||||
}
|
||||
},
|
||||
"required": ["query"],
|
||||
"additionalProperties": false
|
||||
}"#;
|
||||
|
||||
export!(WebSearchTool);
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_url_encode() {
|
||||
assert_eq!(url_encode("hello world"), "hello%20world");
|
||||
assert_eq!(url_encode("foo&bar=baz"), "foo%26bar%3Dbaz");
|
||||
assert_eq!(url_encode("simple"), "simple");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_hostname() {
|
||||
assert_eq!(
|
||||
extract_hostname("https://example.com/path"),
|
||||
Some("example.com".into())
|
||||
);
|
||||
assert_eq!(
|
||||
extract_hostname("https://sub.example.com:8080/path"),
|
||||
Some("sub.example.com".into())
|
||||
);
|
||||
assert_eq!(
|
||||
extract_hostname("http://example.com"),
|
||||
Some("example.com".into())
|
||||
);
|
||||
assert_eq!(extract_hostname("not-a-url"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_valid_lang_code() {
|
||||
assert!(is_valid_lang_code("en"));
|
||||
assert!(is_valid_lang_code("de"));
|
||||
assert!(!is_valid_lang_code("EN")); // must be lowercase
|
||||
assert!(!is_valid_lang_code("eng")); // too long
|
||||
assert!(!is_valid_lang_code("")); // empty
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_valid_country_code() {
|
||||
assert!(is_valid_country_code("US"));
|
||||
assert!(is_valid_country_code("DE"));
|
||||
assert!(!is_valid_country_code("us")); // must be uppercase
|
||||
assert!(!is_valid_country_code("USA")); // too long
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_valid_ui_lang() {
|
||||
assert!(is_valid_ui_lang("en-US"));
|
||||
assert!(is_valid_ui_lang("de-DE"));
|
||||
assert!(!is_valid_ui_lang("en"));
|
||||
assert!(!is_valid_ui_lang("EN-US")); // lang part must be lowercase
|
||||
assert!(!is_valid_ui_lang("en-us")); // country part must be uppercase
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_valid_freshness() {
|
||||
assert!(is_valid_freshness("pd"));
|
||||
assert!(is_valid_freshness("pw"));
|
||||
assert!(is_valid_freshness("pm"));
|
||||
assert!(is_valid_freshness("py"));
|
||||
assert!(is_valid_freshness("2024-01-01to2024-12-31"));
|
||||
assert!(!is_valid_freshness("invalid"));
|
||||
assert!(!is_valid_freshness("2024-01-01")); // missing end date
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_date_like() {
|
||||
assert!(is_date_like("2024-01-15"));
|
||||
assert!(is_date_like("2025-12-31"));
|
||||
assert!(!is_date_like("2024-1-15")); // not zero-padded
|
||||
assert!(!is_date_like("24-01-15")); // short year
|
||||
assert!(!is_date_like("")); // empty
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_search_url_minimal() {
|
||||
let params = SearchParams {
|
||||
query: "test query".to_string(),
|
||||
count: None,
|
||||
country: None,
|
||||
search_lang: None,
|
||||
ui_lang: None,
|
||||
freshness: None,
|
||||
};
|
||||
let url = build_search_url("test query", 5, ¶ms);
|
||||
assert!(url.starts_with(BRAVE_SEARCH_ENDPOINT));
|
||||
assert!(url.contains("q=test%20query"));
|
||||
assert!(url.contains("count=5"));
|
||||
assert!(!url.contains("country="));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_search_url_full() {
|
||||
let params = SearchParams {
|
||||
query: "rust programming".to_string(),
|
||||
count: Some(10),
|
||||
country: Some("US".to_string()),
|
||||
search_lang: Some("en".to_string()),
|
||||
ui_lang: Some("en-US".to_string()),
|
||||
freshness: Some("pw".to_string()),
|
||||
};
|
||||
let url = build_search_url("rust programming", 10, ¶ms);
|
||||
assert!(url.contains("q=rust%20programming"));
|
||||
assert!(url.contains("count=10"));
|
||||
assert!(url.contains("country=US"));
|
||||
assert!(url.contains("search_lang=en"));
|
||||
assert!(url.contains("ui_lang=en-US"));
|
||||
assert!(url.contains("freshness=pw"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_url_encode_multibyte() {
|
||||
assert_eq!(url_encode("café"), "caf%C3%A9");
|
||||
assert_eq!(url_encode("日本語"), "%E6%97%A5%E6%9C%AC%E8%AA%9E");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_hostname_empty() {
|
||||
assert_eq!(extract_hostname("https://"), None);
|
||||
assert_eq!(extract_hostname("https:///path"), None);
|
||||
assert_eq!(extract_hostname(""), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"capabilities": {
|
||||
"http": {
|
||||
"allowlist": [
|
||||
{
|
||||
"host": "api.search.brave.com",
|
||||
"path_prefix": "/res/v1/web/search",
|
||||
"methods": [
|
||||
"GET"
|
||||
]
|
||||
}
|
||||
],
|
||||
"credentials": {
|
||||
"brave_api_key": {
|
||||
"secret_name": "brave_api_key",
|
||||
"location": {
|
||||
"type": "header",
|
||||
"name": "X-Subscription-Token"
|
||||
},
|
||||
"host_patterns": [
|
||||
"api.search.brave.com"
|
||||
]
|
||||
}
|
||||
},
|
||||
"rate_limit": {
|
||||
"requests_per_minute": 30,
|
||||
"requests_per_hour": 500
|
||||
}
|
||||
},
|
||||
"secrets": {
|
||||
"allowed_names": [
|
||||
"brave_api_key"
|
||||
]
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"secret_name": "brave_api_key",
|
||||
"display_name": "Brave Search",
|
||||
"instructions": "Get a free API key at brave.com/search/api/ (Free tier: 2,000 queries/month)",
|
||||
"setup_url": "https://brave.com/search/api/",
|
||||
"env_var": "BRAVE_API_KEY"
|
||||
},
|
||||
"setup": {
|
||||
"required_secrets": [
|
||||
{
|
||||
"name": "brave_api_key",
|
||||
"prompt": "Brave Search API key (from brave.com/search/api)"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user