use super::{Capability, CapabilityStatus, RiskLevel};
use crate::tool_types::ToolHints;
use crate::tools::{Tool, ToolExecutionResult};
use crate::traits::{SessionFileSystem, ToolContext};
use crate::typed_id::SessionId;
use async_trait::async_trait;
use base64::Engine as _;
use fetchkit::file_saver::{FileSaveError, FileSaver, SaveResult};
use fetchkit::{BotAuthConfig, FetchError, FetchRequest};
use serde_json::Value;
use std::sync::Arc;
#[derive(Debug, Clone)]
pub struct BotAuthPublicKey {
pub key_id: String,
pub jwk: serde_json::Value,
}
pub fn derive_bot_auth_public_key(base64_seed: &str) -> Option<BotAuthPublicKey> {
use base64::Engine as _;
use ed25519_dalek::SigningKey;
use sha2::{Digest, Sha256};
let seed_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(base64_seed)
.ok()?;
if seed_bytes.len() != 32 {
return None;
}
let mut seed = [0u8; 32];
seed.copy_from_slice(&seed_bytes);
let signing_key = SigningKey::from_bytes(&seed);
let public_key = signing_key.verifying_key();
let public_key_b64 =
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(public_key.as_bytes());
let canonical_jwk = format!(
r#"{{"crv":"Ed25519","kty":"OKP","x":"{}"}}"#,
public_key_b64
);
let thumbprint = Sha256::digest(canonical_jwk.as_bytes());
let key_id = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(thumbprint);
let jwk = serde_json::json!({
"kty": "OKP",
"crv": "Ed25519",
"x": public_key_b64,
});
Some(BotAuthPublicKey { key_id, jwk })
}
pub struct WebFetchCapability {
bot_auth: Option<BotAuthConfig>,
}
impl WebFetchCapability {
pub fn new(bot_auth: Option<BotAuthConfig>) -> Self {
Self { bot_auth }
}
pub fn from_env() -> Self {
Self {
bot_auth: bot_auth_config_from_env(),
}
}
}
fn bot_auth_config_from_env() -> Option<BotAuthConfig> {
let seed = std::env::var("BOT_AUTH_SIGNING_KEY_SEED").ok()?;
let mut config = match BotAuthConfig::from_base64_seed(&seed) {
Ok(c) => c,
Err(e) => {
tracing::warn!(error = %e, "invalid BOT_AUTH_SIGNING_KEY_SEED, bot-auth disabled");
return None;
}
};
if let Ok(fqdn) = std::env::var("BOT_AUTH_AGENT_FQDN") {
config = config.with_agent_fqdn(&fqdn);
}
if let Ok(secs) = std::env::var("BOT_AUTH_VALIDITY_SECS")
&& let Ok(secs) = secs.parse::<u64>()
{
config = config.with_validity_secs(secs);
}
tracing::info!("bot-auth request signing enabled");
Some(config)
}
#[async_trait]
impl Capability for WebFetchCapability {
fn id(&self) -> &str {
"web_fetch"
}
fn name(&self) -> &str {
"Web Fetch"
}
fn description(&self) -> &str {
fetchkit::TOOL_DESCRIPTION
}
fn status(&self) -> CapabilityStatus {
CapabilityStatus::Available
}
fn risk_level(&self) -> RiskLevel {
RiskLevel::High
}
fn icon(&self) -> Option<&str> {
Some("globe")
}
fn category(&self) -> Option<&str> {
Some("Network")
}
fn system_prompt_addition(&self) -> Option<&str> {
None
}
fn system_prompt_preview(&self) -> Option<String> {
Some(
fetchkit::Tool::builder()
.enable_save_to_file(true)
.build()
.llmtxt(),
)
}
async fn system_prompt_contribution_with_config(
&self,
_ctx: &super::SystemPromptContext,
config: &serde_json::Value,
) -> Option<String> {
let enable_file_download = config
.get("enable_file_download")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let body = if enable_file_download {
"`web_fetch` fetches one URL (GET/HEAD); it is not a search engine. For large or binary responses, pass `save_to_file` to write the body to the workspace instead of inlining it."
} else {
"`web_fetch` fetches one URL (GET/HEAD); it is not a search engine."
};
Some(format!(
"<capability id=\"{}\">\n{}\n</capability>",
self.id(),
body
))
}
fn tools(&self) -> Vec<Box<dyn Tool>> {
vec![Box::new(WebFetchTool::new(false, self.bot_auth.clone()))]
}
fn tools_with_config(&self, config: &serde_json::Value) -> Vec<Box<dyn Tool>> {
let enable_file_download = config
.get("enable_file_download")
.and_then(|v| v.as_bool())
.unwrap_or(false);
vec![Box::new(WebFetchTool::new(
enable_file_download,
self.bot_auth.clone(),
))]
}
}
struct SessionFileSaver {
file_store: Arc<dyn SessionFileSystem>,
session_id: SessionId,
}
#[async_trait]
impl FileSaver for SessionFileSaver {
async fn save(&self, path: &str, bytes: &[u8]) -> Result<SaveResult, FileSaveError> {
let (content, encoding) = match std::str::from_utf8(bytes) {
Ok(text) => (text.to_string(), "text"),
Err(_) => {
let encoded = base64::engine::general_purpose::STANDARD.encode(bytes);
(encoded, "base64")
}
};
let file = self
.file_store
.write_file(self.session_id, path, &content, encoding)
.await
.map_err(|e| FileSaveError::Other(e.to_string()))?;
Ok(SaveResult {
path: file.path,
bytes_written: bytes.len() as u64,
})
}
}
pub struct WebFetchTool {
fetchkit_tool: fetchkit::Tool,
enable_save_to_file: bool,
description: String,
}
impl WebFetchTool {
pub fn new(enable_save_to_file: bool, bot_auth: Option<BotAuthConfig>) -> Self {
let mut builder = fetchkit::Tool::builder().enable_save_to_file(enable_save_to_file);
if let Some(config) = bot_auth {
builder = builder.bot_auth(config);
}
let fetchkit_tool = builder.build();
let description = fetchkit_tool.description().to_string();
Self {
fetchkit_tool,
enable_save_to_file,
description,
}
}
}
impl Default for WebFetchTool {
fn default() -> Self {
Self::new(false, None)
}
}
impl WebFetchTool {
fn parse_request(arguments: &Value) -> Result<FetchRequest, ToolExecutionResult> {
let url = match arguments.get("url").and_then(|v| v.as_str()) {
Some(u) => u.to_string(),
None => {
return Err(ToolExecutionResult::tool_error(
"Missing required parameter: url",
));
}
};
let method = arguments
.get("method")
.and_then(|v| v.as_str())
.map(|s| match s.to_uppercase().as_str() {
"GET" => Some(fetchkit::HttpMethod::Get),
"HEAD" => Some(fetchkit::HttpMethod::Head),
_ => None,
})
.unwrap_or(Some(fetchkit::HttpMethod::Get));
let method = match method {
Some(m) => m,
None => {
return Err(ToolExecutionResult::tool_error(
"Invalid method: must be GET or HEAD",
));
}
};
let as_markdown = arguments
.get("as_markdown")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let as_text = arguments
.get("as_text")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let save_to_file = arguments
.get("save_to_file")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
Ok(FetchRequest {
url,
method: Some(method),
as_markdown: if as_markdown { Some(true) } else { None },
as_text: if as_text { Some(true) } else { None },
save_to_file,
content_focus: None,
if_none_match: None,
if_modified_since: None,
})
}
fn map_error(e: FetchError) -> ToolExecutionResult {
let error_message = match e {
FetchError::MissingUrl => "Missing required parameter: url".to_string(),
FetchError::InvalidUrlScheme => {
"Invalid URL: must start with http:// or https://".to_string()
}
FetchError::InvalidMethod => "Invalid method: must be GET or HEAD".to_string(),
FetchError::BlockedUrl => "URL is blocked by policy".to_string(),
FetchError::ClientBuildError(_) => "Failed to create HTTP client".to_string(),
FetchError::FirstByteTimeout => {
"Request timed out: server did not respond within 1 second".to_string()
}
FetchError::ConnectError(_) => "Failed to connect to server".to_string(),
FetchError::RequestError(msg) => format!("Request failed: {msg}"),
FetchError::FetcherError(msg) => format!("Fetch error: {msg}"),
FetchError::SaveError(msg) => format!("Failed to save file: {msg}"),
FetchError::SaverNotAvailable => "File saving not available".to_string(),
};
ToolExecutionResult::tool_error(error_message)
}
}
#[async_trait]
impl Tool for WebFetchTool {
fn name(&self) -> &str {
"web_fetch"
}
fn display_name(&self) -> Option<&str> {
Some("Web Fetch")
}
fn description(&self) -> &str {
&self.description
}
fn parameters_schema(&self) -> Value {
self.fetchkit_tool.input_schema()
}
fn requires_context(&self) -> bool {
true
}
fn hints(&self) -> ToolHints {
ToolHints::default()
.with_readonly(true)
.with_open_world(true)
.with_long_running(true)
}
async fn execute(&self, arguments: Value) -> ToolExecutionResult {
let request = match Self::parse_request(&arguments) {
Ok(mut req) => {
req.save_to_file = None; req
}
Err(e) => return e,
};
match self.fetchkit_tool.execute(request).await {
Ok(response) => {
ToolExecutionResult::success(serde_json::to_value(&response).unwrap_or_else(
|_| serde_json::json!({"error": "Failed to serialize response"}),
))
}
Err(e) => Self::map_error(e),
}
}
async fn execute_with_context(
&self,
arguments: Value,
context: &ToolContext,
) -> ToolExecutionResult {
let request = match Self::parse_request(&arguments) {
Ok(req) => req,
Err(e) => return e,
};
if request.save_to_file.is_some() && !self.enable_save_to_file {
return ToolExecutionResult::tool_error(
"File download is disabled for this capability",
);
}
if let Some(ref acl) = context.network_access
&& !acl.is_url_allowed(&request.url)
{
return ToolExecutionResult::tool_error(format!(
"URL blocked by network access policy: {}",
request.url
));
}
if request.save_to_file.is_none() {
return match self.fetchkit_tool.execute(request).await {
Ok(response) => {
ToolExecutionResult::success(serde_json::to_value(&response).unwrap_or_else(
|_| serde_json::json!({"error": "Failed to serialize response"}),
))
}
Err(e) => Self::map_error(e),
};
}
let file_store = match &context.file_store {
Some(store) => store.clone(),
None => {
return ToolExecutionResult::tool_error(
"File system not available in this context",
);
}
};
let saver = SessionFileSaver {
file_store,
session_id: context.session_id,
};
match self
.fetchkit_tool
.execute_with_saver(request, Some(&saver))
.await
{
Ok(response) => {
ToolExecutionResult::success(serde_json::to_value(&response).unwrap_or_else(
|_| serde_json::json!({"error": "Failed to serialize response"}),
))
}
Err(e) => Self::map_error(e),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::typed_id::SessionId;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn tool_for_wiremock() -> WebFetchTool {
let fetchkit_tool = fetchkit::Tool::builder()
.enable_save_to_file(true)
.block_private_ips(false)
.build();
let description = fetchkit_tool.description().to_string();
WebFetchTool {
fetchkit_tool,
enable_save_to_file: true,
description,
}
}
#[test]
fn test_derive_bot_auth_public_key() {
let seed = "QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUE";
let pk = super::derive_bot_auth_public_key(seed).unwrap();
assert_eq!(pk.jwk["kty"], "OKP");
assert_eq!(pk.jwk["crv"], "Ed25519");
assert!(pk.jwk["x"].is_string());
let fetchkit_config = fetchkit::BotAuthConfig::from_base64_seed(seed).unwrap();
assert_eq!(pk.key_id, fetchkit_config.keyid());
}
#[test]
fn test_derive_bot_auth_public_key_invalid_seed() {
assert!(super::derive_bot_auth_public_key("tooshort").is_none());
assert!(super::derive_bot_auth_public_key("!!!invalid!!!").is_none());
}
#[test]
fn test_web_fetch_tool_parameters() {
let tool = WebFetchTool::default();
let schema = tool.parameters_schema();
assert_eq!(schema["type"], "object");
assert!(schema["properties"]["url"].is_object());
assert!(schema["properties"]["method"].is_object());
assert!(schema["properties"]["as_markdown"].is_object());
assert!(schema["properties"]["as_text"].is_object());
assert_eq!(schema["required"], serde_json::json!(["url"]));
}
#[test]
fn test_web_fetch_capability_metadata() {
let cap = WebFetchCapability::new(None);
assert_eq!(cap.id(), "web_fetch");
assert_eq!(cap.name(), "Web Fetch");
assert_eq!(cap.status(), CapabilityStatus::Available);
assert_eq!(cap.risk_level(), RiskLevel::High);
assert_eq!(cap.icon(), Some("globe"));
assert_eq!(cap.category(), Some("Network"));
assert!(cap.system_prompt_addition().is_none());
let preview = cap.system_prompt_preview().unwrap();
assert!(preview.contains("web_fetch"));
}
#[test]
fn test_web_fetch_capability_has_tool() {
let cap = WebFetchCapability::new(None);
let tools = cap.tools();
assert_eq!(tools.len(), 1);
assert_eq!(tools[0].name(), "web_fetch");
}
#[tokio::test]
async fn test_web_fetch_missing_url() {
let tool = WebFetchTool::default();
let result = tool.execute(serde_json::json!({})).await;
if let ToolExecutionResult::ToolError(msg) = result {
assert!(msg.contains("url"));
} else {
panic!("Expected tool error for missing URL");
}
}
#[tokio::test]
async fn test_web_fetch_invalid_url() {
let tool = WebFetchTool::default();
let result = tool
.execute(serde_json::json!({"url": "not-a-valid-url"}))
.await;
if let ToolExecutionResult::ToolError(msg) = result {
assert!(msg.contains("Invalid URL"));
} else {
panic!("Expected tool error for invalid URL");
}
}
#[tokio::test]
async fn test_web_fetch_invalid_method() {
let tool = WebFetchTool::default();
let result = tool
.execute(serde_json::json!({"url": "https://example.com", "method": "POST"}))
.await;
if let ToolExecutionResult::ToolError(msg) = result {
assert!(msg.contains("Invalid method"));
} else {
panic!("Expected tool error for invalid method");
}
}
#[tokio::test]
async fn test_web_fetch_real_request() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/html"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string("<html><body><p>Herman Melville - Moby Dick</p></body></html>")
.insert_header("content-type", "text/html"),
)
.mount(&mock_server)
.await;
let tool = tool_for_wiremock();
let result = tool
.execute(serde_json::json!({
"url": format!("{}/html", mock_server.uri()),
"as_text": true
}))
.await;
if let ToolExecutionResult::Success(value) = result {
assert_eq!(value["status_code"], 200);
assert!(
value["content"]
.as_str()
.unwrap()
.contains("Herman Melville")
);
} else {
panic!("Expected successful response");
}
}
#[tokio::test]
async fn test_web_fetch_head_request() {
let mock_server = MockServer::start().await;
Mock::given(method("HEAD"))
.and(path("/html"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "text/html")
.insert_header("content-length", "100"),
)
.mount(&mock_server)
.await;
let tool = tool_for_wiremock();
let result = tool
.execute(serde_json::json!({
"url": format!("{}/html", mock_server.uri()),
"method": "HEAD"
}))
.await;
if let ToolExecutionResult::Success(value) = result {
assert_eq!(value["status_code"], 200);
assert_eq!(value["method"], "HEAD");
assert!(value.get("content").is_none() || value["content"].is_null());
} else {
panic!("Expected successful response");
}
}
#[tokio::test]
async fn test_web_fetch_response_includes_size() {
let mock_server = MockServer::start().await;
let body = "<html><body>Test content</body></html>";
Mock::given(method("GET"))
.and(path("/html"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(body)
.insert_header("content-type", "text/html"),
)
.mount(&mock_server)
.await;
let tool = tool_for_wiremock();
let result = tool
.execute(serde_json::json!({
"url": format!("{}/html", mock_server.uri())
}))
.await;
if let ToolExecutionResult::Success(value) = result {
assert_eq!(value["status_code"], 200);
assert!(value["size"].as_u64().unwrap() > 0);
} else {
panic!("Expected successful response");
}
}
#[tokio::test]
async fn test_web_fetch_binary_returns_metadata() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/image/png"))
.respond_with(
ResponseTemplate::new(200)
.set_body_bytes(vec![0x89, 0x50, 0x4E, 0x47]) .insert_header("content-type", "image/png")
.insert_header("content-length", "4"),
)
.mount(&mock_server)
.await;
let tool = tool_for_wiremock();
let result = tool
.execute(serde_json::json!({
"url": format!("{}/image/png", mock_server.uri())
}))
.await;
if let ToolExecutionResult::Success(value) = result {
assert_eq!(value["status_code"], 200);
assert!(
value["content_type"]
.as_str()
.unwrap()
.contains("image/png")
);
assert!(
value["error"].as_str().unwrap().contains("Binary content")
|| value["error"].as_str().unwrap().contains("binary")
);
assert!(value.get("size").is_some() || value["size"].is_null());
} else {
panic!("Expected success response with metadata for binary content");
}
}
#[tokio::test]
async fn test_web_fetch_truncated_field() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/html"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string("<html><body>Short content</body></html>")
.insert_header("content-type", "text/html"),
)
.mount(&mock_server)
.await;
let tool = tool_for_wiremock();
let result = tool
.execute(serde_json::json!({
"url": format!("{}/html", mock_server.uri())
}))
.await;
if let ToolExecutionResult::Success(value) = result {
assert!(
value["truncated"].is_null()
|| value["truncated"] == false
|| value.get("truncated").is_none()
);
} else {
panic!("Expected successful response");
}
}
#[tokio::test]
async fn test_web_fetch_timeout_unreachable_host() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/slow"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string("slow response")
.set_delay(std::time::Duration::from_secs(5)),
)
.mount(&mock_server)
.await;
let tool = tool_for_wiremock();
let result = tool
.execute(serde_json::json!({
"url": format!("{}/slow", mock_server.uri())
}))
.await;
match result {
ToolExecutionResult::ToolError(msg) => {
assert!(
msg.contains("timed out") || msg.contains("connect") || msg.contains("failed"),
"Expected timeout or connection error, got: {}",
msg
);
}
_ => {
}
}
}
#[tokio::test]
async fn test_web_fetch_response_has_all_expected_fields() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/html"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string("<html><body>Test</body></html>")
.insert_header("content-type", "text/html"),
)
.mount(&mock_server)
.await;
let tool = tool_for_wiremock();
let result = tool
.execute(serde_json::json!({
"url": format!("{}/html", mock_server.uri())
}))
.await;
if let ToolExecutionResult::Success(value) = result {
assert!(value.get("url").is_some(), "Missing 'url' field");
assert!(
value.get("status_code").is_some(),
"Missing 'status_code' field"
);
assert!(
value.get("content_type").is_some(),
"Missing 'content_type' field"
);
assert!(value.get("size").is_some(), "Missing 'size' field");
} else {
panic!("Expected successful response");
}
}
#[tokio::test]
async fn test_web_fetch_head_response_structure() {
let mock_server = MockServer::start().await;
Mock::given(method("HEAD"))
.and(path("/html"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "text/html")
.insert_header("content-length", "100"),
)
.mount(&mock_server)
.await;
let tool = tool_for_wiremock();
let result = tool
.execute(serde_json::json!({
"url": format!("{}/html", mock_server.uri()),
"method": "HEAD"
}))
.await;
if let ToolExecutionResult::Success(value) = result {
assert!(value.get("url").is_some());
assert!(value.get("status_code").is_some());
assert!(value.get("method").is_some());
assert_eq!(value["method"], "HEAD");
assert!(value.get("content").is_none() || value["content"].is_null());
} else {
panic!("Expected successful response");
}
}
#[tokio::test]
async fn test_web_fetch_html_returns_markdown_by_default() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/html"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(
"<!DOCTYPE html><html><body><h1>Title</h1><p>Content</p></body></html>",
)
.insert_header("content-type", "text/html"),
)
.mount(&mock_server)
.await;
let tool = tool_for_wiremock();
let result = tool
.execute(serde_json::json!({
"url": format!("{}/html", mock_server.uri())
}))
.await;
if let ToolExecutionResult::Success(value) = result {
assert_eq!(value["status_code"], 200);
let content = value["content"].as_str().unwrap();
assert!(content.contains("Title") || content.contains("Content"));
let format = value["format"].as_str().unwrap_or("raw");
assert!(format == "markdown" || format == "raw");
} else {
panic!("Expected successful response");
}
}
#[tokio::test]
async fn test_web_fetch_as_text_strips_html() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/html"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string("<!DOCTYPE html><html><body><b>Test</b> content</body></html>")
.insert_header("content-type", "text/html"),
)
.mount(&mock_server)
.await;
let tool = tool_for_wiremock();
let result = tool
.execute(serde_json::json!({
"url": format!("{}/html", mock_server.uri()),
"as_text": true
}))
.await;
if let ToolExecutionResult::Success(value) = result {
assert_eq!(value["status_code"], 200);
let content = value["content"].as_str().unwrap();
assert!(content.contains("Test") || content.contains("content"));
let format = value["format"].as_str().unwrap_or("raw");
assert!(format == "text" || format == "raw");
} else {
panic!("Expected successful response");
}
}
#[tokio::test]
async fn test_web_fetch_raw_format_for_non_html() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/json"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string("{\"key\": \"value\"}")
.insert_header("content-type", "application/json"),
)
.mount(&mock_server)
.await;
let tool = tool_for_wiremock();
let result = tool
.execute(serde_json::json!({
"url": format!("{}/json", mock_server.uri())
}))
.await;
if let ToolExecutionResult::Success(value) = result {
assert_eq!(value["format"], "raw");
} else {
panic!("Expected successful response");
}
}
#[tokio::test]
async fn test_web_fetch_404_returns_success_with_status() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/status/404"))
.respond_with(ResponseTemplate::new(404).set_body_string("Not Found"))
.mount(&mock_server)
.await;
let tool = tool_for_wiremock();
let result = tool
.execute(serde_json::json!({
"url": format!("{}/status/404", mock_server.uri())
}))
.await;
if let ToolExecutionResult::Success(value) = result {
assert_eq!(value["status_code"], 404);
} else {
panic!("Expected successful response even for 404");
}
}
#[tokio::test]
async fn test_web_fetch_500_returns_success_with_status() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/status/500"))
.respond_with(ResponseTemplate::new(500).set_body_string("Internal Server Error"))
.mount(&mock_server)
.await;
let tool = tool_for_wiremock();
let result = tool
.execute(serde_json::json!({
"url": format!("{}/status/500", mock_server.uri())
}))
.await;
if let ToolExecutionResult::Success(value) = result {
assert_eq!(value["status_code"], 500);
} else {
panic!("Expected successful response even for 500");
}
}
#[tokio::test]
async fn test_web_fetch_dns_failure() {
let tool = WebFetchTool::default();
let result = tool
.execute(serde_json::json!({
"url": "https://this-domain-definitely-does-not-exist-12345.com/test"
}))
.await;
if let ToolExecutionResult::ToolError(msg) = result {
let msg_lower = msg.to_lowercase();
assert!(
msg_lower.contains("failed")
|| msg_lower.contains("error")
|| msg_lower.contains("timed out")
|| msg_lower.contains("connect")
|| msg_lower.contains("blocked"),
"Expected error message about failure, got: {}",
msg
);
} else {
}
}
#[tokio::test]
async fn test_web_fetch_rejects_ftp_url() {
let tool = WebFetchTool::default();
let result = tool
.execute(serde_json::json!({
"url": "ftp://example.com/file.txt"
}))
.await;
if let ToolExecutionResult::ToolError(msg) = result {
assert!(msg.contains("Invalid URL"));
} else {
panic!("Expected tool error for FTP URL");
}
}
#[tokio::test]
async fn test_web_fetch_rejects_file_url() {
let tool = WebFetchTool::default();
let result = tool
.execute(serde_json::json!({
"url": "file:///etc/passwd"
}))
.await;
if let ToolExecutionResult::ToolError(msg) = result {
assert!(msg.contains("Invalid URL"));
} else {
panic!("Expected tool error for file:// URL");
}
}
#[tokio::test]
async fn test_web_fetch_accepts_http_url() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/get"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string("{\"url\": \"http://localhost/get\"}")
.insert_header("content-type", "application/json"),
)
.mount(&mock_server)
.await;
let tool = tool_for_wiremock();
let result = tool
.execute(serde_json::json!({
"url": format!("{}/get", mock_server.uri())
}))
.await;
if let ToolExecutionResult::Success(value) = result {
assert_eq!(value["status_code"], 200);
} else {
panic!("Expected successful response for HTTP URL");
}
}
#[tokio::test]
async fn test_web_fetch_filters_excessive_newlines() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/newlines"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string("line1\n\n\n\n\n\n\n\nline2")
.insert_header("content-type", "text/plain"),
)
.mount(&mock_server)
.await;
let tool = tool_for_wiremock();
let result = tool
.execute(serde_json::json!({
"url": format!("{}/newlines", mock_server.uri())
}))
.await;
if let ToolExecutionResult::Success(value) = result {
let content = value["content"].as_str().unwrap();
assert!(
!content.contains("\n\n\n"),
"Content should not have more than 2 consecutive newlines"
);
} else {
panic!("Expected successful response");
}
}
async fn assert_blocked_by_policy(url: &str) {
let tool = WebFetchTool::default();
let result = tool.execute(serde_json::json!({"url": url})).await;
assert!(
matches!(&result, ToolExecutionResult::ToolError(msg) if msg.contains("blocked")),
"Expected URL {url} to be blocked by policy, got: {:?}",
result
);
}
#[tokio::test]
async fn test_ssrf_cloud_metadata_blocked() {
assert_blocked_by_policy("http://169.254.169.254/latest/meta-data/").await;
}
#[tokio::test]
async fn test_ssrf_localhost_blocked() {
assert_blocked_by_policy("http://127.0.0.1:1/").await;
}
#[tokio::test]
async fn test_ssrf_private_10_blocked() {
assert_blocked_by_policy("http://10.0.0.1:1/").await;
}
#[tokio::test]
async fn test_ssrf_private_172_blocked() {
assert_blocked_by_policy("http://172.16.0.1:1/").await;
}
#[tokio::test]
async fn test_ssrf_private_192_blocked() {
assert_blocked_by_policy("http://192.168.0.1:1/").await;
}
#[tokio::test]
async fn test_ssrf_ipv6_localhost_blocked() {
assert_blocked_by_policy("http://[::1]:1/").await;
}
#[tokio::test]
async fn test_ssrf_unspecified_blocked() {
assert_blocked_by_policy("http://0.0.0.0:1/").await;
}
#[tokio::test]
async fn test_ssrf_non_http_schemes_blocked() {
let tool = WebFetchTool::default();
for (scheme, url) in [
("file://", "file:///etc/passwd"),
("ftp://", "ftp://internal-server/data"),
("gopher://", "gopher://internal-server/"),
] {
let result = tool.execute(serde_json::json!({"url": url})).await;
assert!(
matches!(&result, ToolExecutionResult::ToolError(msg) if msg.contains("Invalid URL")),
"{scheme} should be rejected"
);
}
}
#[tokio::test]
async fn test_fetch_html_page() {
let mock_server = MockServer::start().await;
let html = r#"<html><head><title>Wasmtime Docs</title></head>
<body><h1>Wasmtime</h1><p>A fast and secure runtime for WebAssembly.</p>
<p>Wasmtime is a standalone runtime for WebAssembly that can be used
as a CLI tool or embedded into other systems.</p></body></html>"#;
Mock::given(method("GET"))
.and(path("/"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(html)
.insert_header("content-type", "text/html; charset=utf-8"),
)
.mount(&mock_server)
.await;
let tool = tool_for_wiremock();
let result = tool
.execute(serde_json::json!({
"url": format!("{}/", mock_server.uri())
}))
.await;
if let ToolExecutionResult::Success(value) = result {
assert_eq!(value["status_code"], 200);
let content = value["content"].as_str().unwrap();
assert!(
content.contains("Wasmtime") || content.contains("wasmtime"),
"Content should mention Wasmtime"
);
assert!(
value["size"].as_u64().unwrap() > 100,
"Page should have substantial content"
);
} else {
panic!("Expected successful response, got: {:?}", result);
}
}
#[tokio::test]
async fn test_fetch_html_as_text() {
let mock_server = MockServer::start().await;
let html = r#"<html><head><title>Wasmtime Docs</title></head>
<body><h1>Wasmtime</h1><p>A fast and secure runtime.</p></body></html>"#;
Mock::given(method("GET"))
.and(path("/"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(html)
.insert_header("content-type", "text/html"),
)
.mount(&mock_server)
.await;
let tool = tool_for_wiremock();
let result = tool
.execute(serde_json::json!({
"url": format!("{}/", mock_server.uri()),
"as_text": true
}))
.await;
if let ToolExecutionResult::Success(value) = result {
assert_eq!(value["status_code"], 200);
let content = value["content"].as_str().unwrap();
assert!(
content.contains("Wasmtime") || content.contains("wasmtime"),
"Text should contain Wasmtime reference"
);
let format = value["format"].as_str().unwrap_or("raw");
assert!(
format == "text" || format == "raw",
"Format should be text or raw, got: {}",
format
);
} else {
panic!(
"Expected successful response with text conversion, got: {:?}",
result
);
}
}
#[tokio::test]
async fn test_fetch_head_request() {
let mock_server = MockServer::start().await;
Mock::given(method("HEAD"))
.and(path("/"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "text/html; charset=utf-8")
.insert_header("content-length", "5000"),
)
.mount(&mock_server)
.await;
let tool = tool_for_wiremock();
let result = tool
.execute(serde_json::json!({
"url": format!("{}/", mock_server.uri()),
"method": "HEAD"
}))
.await;
if let ToolExecutionResult::Success(value) = result {
assert_eq!(value["status_code"], 200);
assert_eq!(value["method"], "HEAD");
assert!(
value["content"].is_null()
|| value["content"].as_str().is_none_or(|s| s.is_empty()),
"HEAD request should not return content body"
);
assert!(value["content_type"].as_str().is_some());
} else {
panic!("Expected successful HEAD response, got: {:?}", result);
}
}
#[tokio::test]
async fn test_fetch_subpage() {
let mock_server = MockServer::start().await;
let body = format!(
"<html><body><h1>Introduction</h1><p>{}</p></body></html>",
"WebAssembly is a portable binary instruction format. ".repeat(20)
);
Mock::given(method("GET"))
.and(path("/introduction.html"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(&body)
.insert_header("content-type", "text/html"),
)
.mount(&mock_server)
.await;
let tool = tool_for_wiremock();
let result = tool
.execute(serde_json::json!({
"url": format!("{}/introduction.html", mock_server.uri())
}))
.await;
if let ToolExecutionResult::Success(value) = result {
assert_eq!(value["status_code"], 200);
let content = value["content"].as_str().unwrap();
assert!(
content.len() > 500,
"Subpage should have substantial content, got {} bytes",
content.len()
);
} else {
panic!(
"Expected successful response from subpage, got: {:?}",
result
);
}
}
#[tokio::test]
async fn test_fetch_repo_page() {
let mock_server = MockServer::start().await;
let html = r#"<html><body>
<h1>wasm3/wasm3</h1>
<p>The fastest WebAssembly interpreter (and target for wasm3).</p>
<div class="readme"><h2>README</h2><p>wasm3 is a high performance
WebAssembly interpreter written in C.</p></div>
</body></html>"#;
Mock::given(method("GET"))
.and(path("/wasm3/wasm3"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(html)
.insert_header("content-type", "text/html; charset=utf-8"),
)
.mount(&mock_server)
.await;
let tool = tool_for_wiremock();
let result = tool
.execute(serde_json::json!({
"url": format!("{}/wasm3/wasm3", mock_server.uri())
}))
.await;
if let ToolExecutionResult::Success(value) = result {
assert_eq!(value["status_code"], 200);
let content = value["content"].as_str().unwrap();
assert!(
content.to_lowercase().contains("wasm3"),
"Content should mention wasm3"
);
} else {
panic!("Expected successful response, got: {:?}", result);
}
}
#[tokio::test]
async fn test_fetch_repo_page_as_text() {
let mock_server = MockServer::start().await;
let html = r#"<html><body>
<h1>wasm3/wasm3</h1>
<p>The fastest WebAssembly interpreter written in C.</p>
</body></html>"#;
Mock::given(method("GET"))
.and(path("/wasm3/wasm3"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(html)
.insert_header("content-type", "text/html; charset=utf-8"),
)
.mount(&mock_server)
.await;
let tool = tool_for_wiremock();
let result = tool
.execute(serde_json::json!({
"url": format!("{}/wasm3/wasm3", mock_server.uri()),
"as_text": true
}))
.await;
if let ToolExecutionResult::Success(value) = result {
assert_eq!(value["status_code"], 200);
let content = value["content"].as_str().unwrap();
assert!(
content.to_lowercase().contains("wasm3"),
"Content should mention wasm3"
);
} else {
panic!("Expected successful response, got: {:?}", result);
}
}
struct MockFileStore {
files: tokio::sync::Mutex<std::collections::HashMap<(SessionId, String), (String, String)>>,
}
impl MockFileStore {
fn new() -> Self {
Self {
files: tokio::sync::Mutex::new(std::collections::HashMap::new()),
}
}
async fn get_file(&self, session_id: SessionId, path: &str) -> Option<(String, String)> {
self.files
.lock()
.await
.get(&(session_id, path.to_string()))
.cloned()
}
}
#[async_trait]
impl SessionFileSystem for MockFileStore {
async fn read_file(
&self,
session_id: SessionId,
path: &str,
) -> crate::error::Result<Option<crate::session_file::SessionFile>> {
let guard = self.files.lock().await;
if let Some((content, encoding)) = guard.get(&(session_id, path.to_string())) {
Ok(Some(crate::session_file::SessionFile {
id: uuid::Uuid::new_v4(),
session_id: session_id.uuid(),
path: path.to_string(),
name: path.rsplit('/').next().unwrap_or(path).to_string(),
content: Some(content.clone()),
encoding: encoding.clone(),
size_bytes: content.len() as i64,
is_directory: false,
is_readonly: false,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
}))
} else {
Ok(None)
}
}
async fn write_file(
&self,
session_id: SessionId,
path: &str,
content: &str,
encoding: &str,
) -> crate::error::Result<crate::session_file::SessionFile> {
self.files.lock().await.insert(
(session_id, path.to_string()),
(content.to_string(), encoding.to_string()),
);
Ok(crate::session_file::SessionFile {
id: uuid::Uuid::new_v4(),
session_id: session_id.uuid(),
path: path.to_string(),
name: path.rsplit('/').next().unwrap_or(path).to_string(),
content: Some(content.to_string()),
encoding: encoding.to_string(),
size_bytes: content.len() as i64,
is_directory: false,
is_readonly: false,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
})
}
async fn delete_file(
&self,
_session_id: SessionId,
_path: &str,
_recursive: bool,
) -> crate::error::Result<bool> {
Ok(false)
}
async fn list_directory(
&self,
_session_id: SessionId,
_path: &str,
) -> crate::error::Result<Vec<crate::session_file::FileInfo>> {
Ok(vec![])
}
async fn stat_file(
&self,
_session_id: SessionId,
_path: &str,
) -> crate::error::Result<Option<crate::session_file::FileStat>> {
Ok(None)
}
async fn grep_files(
&self,
_session_id: SessionId,
_pattern: &str,
_path_pattern: Option<&str>,
) -> crate::error::Result<Vec<crate::session_file::GrepMatch>> {
Ok(vec![])
}
async fn create_directory(
&self,
_session_id: SessionId,
_path: &str,
) -> crate::error::Result<crate::session_file::FileInfo> {
unimplemented!()
}
}
#[test]
fn test_web_fetch_tool_schema_save_to_file_gated_by_config() {
let tool = WebFetchTool::new(false, None);
let schema = tool.parameters_schema();
assert!(
!schema["properties"]["save_to_file"].is_object(),
"Schema should NOT include save_to_file when disabled"
);
let tool = WebFetchTool::new(true, None);
let schema = tool.parameters_schema();
assert!(
schema["properties"]["save_to_file"].is_object(),
"Schema should include save_to_file when enabled"
);
}
#[test]
fn test_web_fetch_tool_requires_context() {
let tool = WebFetchTool::default();
assert!(tool.requires_context());
}
#[test]
fn test_web_fetch_tools_with_config_enables_file_download() {
let cap = WebFetchCapability::new(None);
let tools = cap.tools_with_config(&serde_json::json!({}));
assert_eq!(tools.len(), 1);
let schema = tools[0].parameters_schema();
assert!(!schema["properties"]["save_to_file"].is_object());
let tools = cap.tools_with_config(&serde_json::json!({"enable_file_download": true}));
assert_eq!(tools.len(), 1);
let schema = tools[0].parameters_schema();
assert!(schema["properties"]["save_to_file"].is_object());
}
#[tokio::test]
async fn test_web_fetch_system_prompt_adapts_to_config() {
let cap = WebFetchCapability::new(None);
let ctx = super::super::SystemPromptContext::without_file_store(SessionId::new());
let prompt = cap
.system_prompt_contribution_with_config(&ctx, &serde_json::json!({}))
.await
.unwrap();
assert!(!prompt.contains("save_to_file"));
let prompt = cap
.system_prompt_contribution_with_config(
&ctx,
&serde_json::json!({"enable_file_download": true}),
)
.await
.unwrap();
assert!(prompt.contains("save_to_file"));
}
#[tokio::test]
async fn test_save_to_file_text_content() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/data.json"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string("{\"key\": \"value\"}")
.insert_header("content-type", "application/json"),
)
.mount(&mock_server)
.await;
let tool = tool_for_wiremock();
let file_store = Arc::new(MockFileStore::new());
let session_id = SessionId::new();
let context = ToolContext::with_file_store(session_id, file_store.clone());
let result = tool
.execute_with_context(
serde_json::json!({
"url": format!("{}/data.json", mock_server.uri()),
"save_to_file": "/downloads/data.json"
}),
&context,
)
.await;
if let ToolExecutionResult::Success(value) = result {
assert_eq!(value["status_code"], 200);
assert!(value["saved_path"].as_str().is_some());
assert!(value["bytes_written"].as_u64().unwrap() > 0);
assert!(
value.get("content").is_none() || value["content"].is_null(),
"Content should not be inline when saving to file"
);
let (content, encoding) = file_store
.get_file(session_id, "/downloads/data.json")
.await
.expect("File should have been written");
assert_eq!(encoding, "text");
assert!(content.contains("value"));
} else {
panic!("Expected successful response, got: {:?}", result);
}
}
#[tokio::test]
async fn test_save_to_file_binary_content() {
let mock_server = MockServer::start().await;
let png_bytes = vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0xFF, 0xFE];
Mock::given(method("GET"))
.and(path("/image.png"))
.respond_with(
ResponseTemplate::new(200)
.set_body_bytes(png_bytes.clone())
.insert_header("content-type", "image/png"),
)
.mount(&mock_server)
.await;
let tool = tool_for_wiremock();
let file_store = Arc::new(MockFileStore::new());
let session_id = SessionId::new();
let context = ToolContext::with_file_store(session_id, file_store.clone());
let result = tool
.execute_with_context(
serde_json::json!({
"url": format!("{}/image.png", mock_server.uri()),
"save_to_file": "/downloads/image.png"
}),
&context,
)
.await;
if let ToolExecutionResult::Success(value) = result {
assert_eq!(value["status_code"], 200);
assert!(value["saved_path"].as_str().is_some());
assert_eq!(
value["bytes_written"].as_u64().unwrap(),
png_bytes.len() as u64
);
let (content, encoding) = file_store
.get_file(session_id, "/downloads/image.png")
.await
.expect("File should have been written");
assert_eq!(encoding, "base64");
let decoded = base64::engine::general_purpose::STANDARD
.decode(&content)
.expect("Should be valid base64");
assert_eq!(decoded, png_bytes);
} else {
panic!("Expected successful response, got: {:?}", result);
}
}
#[tokio::test]
async fn test_save_to_file_no_file_store_returns_error() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/file.txt"))
.respond_with(ResponseTemplate::new(200).set_body_string("content"))
.mount(&mock_server)
.await;
let tool = tool_for_wiremock();
let context = ToolContext::new(SessionId::new());
let result = tool
.execute_with_context(
serde_json::json!({
"url": format!("{}/file.txt", mock_server.uri()),
"save_to_file": "/downloads/file.txt"
}),
&context,
)
.await;
if let ToolExecutionResult::ToolError(msg) = result {
assert!(
msg.contains("not available"),
"Expected file system not available error, got: {}",
msg
);
} else {
panic!("Expected tool error, got: {:?}", result);
}
}
#[tokio::test]
async fn test_save_to_file_disabled_by_config_returns_error() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/file.txt"))
.respond_with(ResponseTemplate::new(200).set_body_string("content"))
.mount(&mock_server)
.await;
let tool = WebFetchTool::new(false, None);
let file_store = Arc::new(MockFileStore::new());
let session_id = SessionId::new();
let context = ToolContext::with_file_store(session_id, file_store.clone());
let result = tool
.execute_with_context(
serde_json::json!({
"url": format!("{}/file.txt", mock_server.uri()),
"save_to_file": "/downloads/file.txt"
}),
&context,
)
.await;
if let ToolExecutionResult::ToolError(msg) = result {
assert!(
msg.contains("disabled"),
"Expected file download disabled error, got: {}",
msg
);
} else {
panic!("Expected tool error, got: {:?}", result);
}
assert!(
file_store
.get_file(session_id, "/downloads/file.txt")
.await
.is_none(),
"File should not be written when save_to_file is disabled",
);
}
#[tokio::test]
async fn test_save_to_file_without_context_strips_save() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/file.txt"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string("hello")
.insert_header("content-type", "text/plain"),
)
.mount(&mock_server)
.await;
let tool = tool_for_wiremock();
let result = tool
.execute(serde_json::json!({
"url": format!("{}/file.txt", mock_server.uri()),
"save_to_file": "/downloads/file.txt"
}))
.await;
if let ToolExecutionResult::Success(value) = result {
assert_eq!(value["status_code"], 200);
assert!(value["content"].as_str().is_some());
assert!(value.get("saved_path").is_none() || value["saved_path"].is_null());
} else {
panic!("Expected successful response, got: {:?}", result);
}
}
}