1use super::{Capability, CapabilityLocalization, CapabilityStatus, RiskLevel};
32use crate::tool_types::ToolHints;
33use crate::tools::{Tool, ToolExecutionResult};
34use crate::traits::{SessionFileSystem, ToolContext};
35use crate::typed_id::SessionId;
36use async_trait::async_trait;
37use base64::Engine as _;
38use fetchkit::file_saver::{FileSaveError, FileSaver, SaveResult};
39use fetchkit::{BotAuthConfig, FetchError, FetchRequest};
40use serde_json::Value;
41use std::sync::Arc;
42
43mod egress_transport;
44
45pub const WEB_FETCH_CAPABILITY_ID: &str = "web_fetch";
46
47#[derive(Debug, Clone)]
52pub struct BotAuthPublicKey {
53 pub key_id: String,
55 pub jwk: serde_json::Value,
57}
58
59pub fn derive_bot_auth_public_key(base64_seed: &str) -> Option<BotAuthPublicKey> {
65 use base64::Engine as _;
66 use ed25519_dalek::SigningKey;
67 use sha2::{Digest, Sha256};
68
69 let seed_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
71 .decode(base64_seed)
72 .ok()?;
73 if seed_bytes.len() != 32 {
74 return None;
75 }
76 let mut seed = [0u8; 32];
77 seed.copy_from_slice(&seed_bytes);
78
79 let signing_key = SigningKey::from_bytes(&seed);
81 let public_key = signing_key.verifying_key();
82 let public_key_b64 =
83 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(public_key.as_bytes());
84
85 let canonical_jwk = format!(
87 r#"{{"crv":"Ed25519","kty":"OKP","x":"{}"}}"#,
88 public_key_b64
89 );
90
91 let thumbprint = Sha256::digest(canonical_jwk.as_bytes());
93 let key_id = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(thumbprint);
94
95 let jwk = serde_json::json!({
96 "kty": "OKP",
97 "crv": "Ed25519",
98 "x": public_key_b64,
99 });
100
101 Some(BotAuthPublicKey { key_id, jwk })
102}
103
104pub struct WebFetchCapability {
111 bot_auth: Option<BotAuthConfig>,
114}
115
116impl WebFetchCapability {
117 pub fn new(bot_auth: Option<BotAuthConfig>) -> Self {
119 Self { bot_auth }
120 }
121
122 pub fn from_env() -> Self {
128 Self {
129 bot_auth: bot_auth_config_from_env(),
130 }
131 }
132}
133
134fn bot_auth_config_from_env() -> Option<BotAuthConfig> {
136 let seed = std::env::var("BOT_AUTH_SIGNING_KEY_SEED").ok()?;
137
138 let mut config = match BotAuthConfig::from_base64_seed(&seed) {
139 Ok(c) => c,
140 Err(e) => {
141 tracing::warn!(error = %e, "invalid BOT_AUTH_SIGNING_KEY_SEED, bot-auth disabled");
142 return None;
143 }
144 };
145
146 if let Ok(fqdn) = std::env::var("BOT_AUTH_AGENT_FQDN") {
147 config = config.with_agent_fqdn(&fqdn);
148 }
149
150 if let Ok(secs) = std::env::var("BOT_AUTH_VALIDITY_SECS")
151 && let Ok(secs) = secs.parse::<u64>()
152 {
153 config = config.with_validity_secs(secs);
154 }
155
156 tracing::info!("bot-auth request signing enabled");
157 Some(config)
158}
159
160#[async_trait]
161impl Capability for WebFetchCapability {
162 fn id(&self) -> &str {
163 WEB_FETCH_CAPABILITY_ID
164 }
165
166 fn name(&self) -> &str {
167 "Web Fetch"
168 }
169
170 fn description(&self) -> &str {
171 fetchkit::TOOL_DESCRIPTION
172 }
173
174 fn status(&self) -> CapabilityStatus {
175 CapabilityStatus::Available
176 }
177
178 fn risk_level(&self) -> RiskLevel {
179 RiskLevel::High
180 }
181
182 fn icon(&self) -> Option<&str> {
183 Some("globe")
184 }
185
186 fn category(&self) -> Option<&str> {
187 Some("Network")
188 }
189
190 fn system_prompt_addition(&self) -> Option<&str> {
191 None
192 }
193
194 fn system_prompt_preview(&self) -> Option<String> {
195 Some(
197 fetchkit::Tool::builder()
198 .enable_save_to_file(true)
199 .enable_render_rakers(true)
200 .build()
201 .llmtxt(),
202 )
203 }
204
205 async fn system_prompt_contribution_with_config(
206 &self,
207 _ctx: &super::SystemPromptContext,
208 config: &serde_json::Value,
209 ) -> Option<String> {
210 let enable_file_download = config
217 .get("enable_file_download")
218 .and_then(|v| v.as_bool())
219 .unwrap_or(false);
220 let body = if enable_file_download {
221 "`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."
222 } else {
223 "`web_fetch` fetches one URL (GET/HEAD); it is not a search engine."
224 };
225 Some(format!(
226 "<capability id=\"{}\">\n{}\n</capability>",
227 self.id(),
228 body
229 ))
230 }
231
232 fn tools(&self) -> Vec<Box<dyn Tool>> {
233 vec![Box::new(WebFetchTool::new(false, self.bot_auth.clone()))]
234 }
235
236 fn tools_with_config(&self, config: &serde_json::Value) -> Vec<Box<dyn Tool>> {
237 let enable_file_download = config
238 .get("enable_file_download")
239 .and_then(|v| v.as_bool())
240 .unwrap_or(false);
241 vec![Box::new(WebFetchTool::new(
242 enable_file_download,
243 self.bot_auth.clone(),
244 ))]
245 }
246
247 fn config_schema(&self) -> Option<serde_json::Value> {
248 Some(serde_json::json!({
249 "type": "object",
250 "properties": {
251 "enable_file_download": {
252 "type": "boolean",
253 "title": "Allow saving fetched files",
254 "description": "Let the web_fetch tool save large or binary responses \
255 into the session workspace via save_to_file instead of \
256 inlining them.",
257 "default": false
258 }
259 }
260 }))
261 }
262
263 fn validate_config(&self, config: &serde_json::Value) -> Result<(), String> {
264 if config.is_null() {
265 return Ok(());
266 }
267 if !config.is_object() {
268 return Err("web_fetch config must be an object".to_string());
269 }
270 match config.get("enable_file_download") {
271 None | Some(serde_json::Value::Bool(_)) => Ok(()),
272 Some(other) => Err(format!(
273 "enable_file_download must be a boolean, got {other}"
274 )),
275 }
276 }
277
278 fn localizations(&self) -> Vec<CapabilityLocalization> {
279 vec![
280 CapabilityLocalization {
281 locale: "en",
282 name: None,
283 description: None,
284 config_description: Some(
285 "Controls whether fetched responses may be saved into the session \
286 workspace.",
287 ),
288 config_overlay: None,
289 },
290 CapabilityLocalization {
291 locale: "uk",
292 name: Some("Отримання вебвмісту"),
293 description: Some(
294 "Отримує вміст за URL-адресою (GET/HEAD) і за потреби зберігає його у \
295 файлову систему сесії.",
296 ),
297 config_description: Some(
298 "Визначає, чи можна зберігати отримані відповіді в робочий простір сесії.",
299 ),
300 config_overlay: Some(serde_json::json!({
301 "properties": {
302 "enable_file_download": {
303 "title": "Дозволити збереження файлів",
304 "description": "Дозволяє інструменту web_fetch зберігати великі або бінарні відповіді у файли робочого простору (save_to_file) замість вбудовування у відповідь."
305 }
306 }
307 })),
308 },
309 ]
310 }
311}
312
313struct SessionFileSaver {
321 file_store: Arc<dyn SessionFileSystem>,
322 session_id: SessionId,
323}
324
325impl SessionFileSaver {
326 async fn resolve_destination(&self, path: &str) -> Result<String, FileSaveError> {
327 let path = path.trim();
328 if path.is_empty() {
329 return Err(FileSaveError::PathNotAllowed(
330 "Destination path must name a file".to_string(),
331 ));
332 }
333
334 let resolved = self.file_store.resolve_path(path);
337 let root = self.file_store.resolve_path("");
338 if resolved == root || resolved == "/" {
339 return Err(FileSaveError::PathNotAllowed(format!(
340 "Destination resolves to the workspace root: {resolved}"
341 )));
342 }
343
344 let existing = self
345 .file_store
346 .stat_file(self.session_id, &resolved)
347 .await
348 .map_err(|error| {
349 FileSaveError::Other(format!(
350 "Could not inspect destination path {resolved}: {error}"
351 ))
352 })?;
353 if existing.is_some_and(|entry| entry.is_directory) {
354 return Err(FileSaveError::PathNotAllowed(format!(
355 "Destination is an existing directory: {resolved}"
356 )));
357 }
358
359 Ok(resolved)
360 }
361}
362
363#[async_trait]
364impl FileSaver for SessionFileSaver {
365 async fn save(&self, path: &str, bytes: &[u8]) -> Result<SaveResult, FileSaveError> {
366 let path = self.resolve_destination(path).await?;
370 let (content, encoding) = match std::str::from_utf8(bytes) {
371 Ok(text) => (text.to_string(), "text"),
372 Err(_) => {
373 let encoded = base64::engine::general_purpose::STANDARD.encode(bytes);
374 (encoded, "base64")
375 }
376 };
377
378 let file = self
379 .file_store
380 .write_file(self.session_id, &path, &content, encoding)
381 .await
382 .map_err(|e| FileSaveError::Other(e.to_string()))?;
383
384 Ok(SaveResult {
385 path: file.path,
386 bytes_written: bytes.len() as u64,
387 })
388 }
389
390 async fn validate_path(&self, path: &str) -> Result<(), FileSaveError> {
391 self.resolve_destination(path).await.map(|_| ())
392 }
393}
394
395pub struct WebFetchTool {
409 builder: fetchkit::ToolBuilder,
413 fetchkit_tool: fetchkit::Tool,
417 enable_save_to_file: bool,
418 description: String,
420 system_allowlist: Option<Arc<crate::system_allowlist::SystemAllowlist>>,
427}
428
429impl WebFetchTool {
430 pub fn new(enable_save_to_file: bool, bot_auth: Option<BotAuthConfig>) -> Self {
432 let mut builder = fetchkit::Tool::builder()
436 .enable_save_to_file(enable_save_to_file)
437 .enable_render_rakers(true);
438 if let Some(config) = bot_auth {
439 builder = builder.bot_auth(config);
440 }
441 let fetchkit_tool = builder.build();
442 let description = fetchkit_tool.description().to_string();
443 Self {
444 builder,
445 fetchkit_tool,
446 enable_save_to_file,
447 description,
448 system_allowlist: crate::system_allowlist::SystemAllowlist::from_env(),
449 }
450 }
451
452 fn system_policy_block(&self, url: &str) -> Option<ToolExecutionResult> {
456 match &self.system_allowlist {
457 Some(allowlist) if !allowlist.is_url_allowed(url) => {
458 Some(ToolExecutionResult::tool_error(format!(
459 "Endpoint blocked by system policy: {url} is not on the allowlist \
460 of permitted public resources."
461 )))
462 }
463 _ => None,
464 }
465 }
466}
467
468impl Default for WebFetchTool {
469 fn default() -> Self {
470 Self::new(false, None)
471 }
472}
473
474impl WebFetchTool {
475 fn parse_request(arguments: &Value) -> Result<FetchRequest, ToolExecutionResult> {
477 let url = match arguments.get("url").and_then(Value::as_str) {
478 Some(url) => url.to_string(),
479 None => {
480 return Err(ToolExecutionResult::tool_error(
481 "Missing required parameter: url",
482 ));
483 }
484 };
485
486 let method = arguments
487 .get("method")
488 .and_then(|v| v.as_str())
489 .map(|s| match s.to_uppercase().as_str() {
490 "GET" => Some(fetchkit::HttpMethod::Get),
491 "HEAD" => Some(fetchkit::HttpMethod::Head),
492 _ => None,
493 })
494 .unwrap_or(Some(fetchkit::HttpMethod::Get));
495
496 let method = match method {
497 Some(m) => m,
498 None => {
499 return Err(ToolExecutionResult::tool_error(
500 "Invalid method: must be GET or HEAD",
501 ));
502 }
503 };
504
505 let mut normalized_arguments = arguments.clone();
510 let object = normalized_arguments
511 .as_object_mut()
512 .ok_or_else(|| ToolExecutionResult::tool_error("Arguments must be a JSON object"))?;
513 object.remove("method");
514 if let Some(path) = object.get("save_to_file").and_then(Value::as_str) {
515 let path = path.trim();
516 if path.is_empty() {
517 object.remove("save_to_file");
518 } else {
519 object.insert("save_to_file".to_string(), Value::String(path.to_string()));
520 }
521 }
522
523 let mut request: FetchRequest =
524 serde_json::from_value(normalized_arguments).map_err(|error| {
525 ToolExecutionResult::tool_error(format!("Invalid arguments: {error}"))
526 })?;
527 request.url = url;
528 request.method = Some(method);
529 Ok(request)
530 }
531
532 fn map_error(e: FetchError) -> ToolExecutionResult {
534 let error_message = match e {
535 FetchError::MissingUrl => "Missing required parameter: url".to_string(),
536 FetchError::InvalidUrlScheme => {
537 "Invalid URL: must start with http:// or https://".to_string()
538 }
539 FetchError::InvalidMethod => "Invalid method: must be GET or HEAD".to_string(),
540 FetchError::BlockedUrl => "URL is blocked by policy".to_string(),
541 FetchError::ClientBuildError(_) => "Failed to create HTTP client".to_string(),
542 FetchError::FirstByteTimeout => {
543 "Request timed out: server did not respond within 1 second".to_string()
544 }
545 FetchError::ConnectError(_) => "Failed to connect to server".to_string(),
546 FetchError::RequestError(msg) => format!("Request failed: {msg}"),
547 FetchError::FetcherError(msg) => format!("Fetch error: {msg}"),
548 FetchError::SaveError(msg) => format!("Failed to save file: {msg}"),
549 FetchError::SaverNotAvailable => "File saving not available".to_string(),
550 FetchError::RenderNotAvailable => "Rendered fetch backend not available".to_string(),
551 };
552 ToolExecutionResult::tool_error(error_message)
553 }
554}
555
556#[async_trait]
557impl Tool for WebFetchTool {
558 fn narrate(
559 &self,
560 tool_call: &crate::tool_types::ToolCall,
561 phase: crate::tool_narration::ToolNarrationPhase,
562 locale: Option<&str>,
563 _ctx: crate::tool_narration::ToolNarrationContext<'_>,
564 ) -> Option<String> {
565 Some(crate::tool_narration::narrate_web_fetch(
566 &tool_call.arguments,
567 phase,
568 locale,
569 ))
570 }
571
572 fn name(&self) -> &str {
573 "web_fetch"
574 }
575
576 fn display_name(&self) -> Option<&str> {
577 Some("Web Fetch")
578 }
579
580 fn description(&self) -> &str {
581 &self.description
582 }
583
584 fn parameters_schema(&self) -> Value {
585 self.fetchkit_tool.input_schema()
586 }
587
588 fn requires_context(&self) -> bool {
589 true
591 }
592
593 fn hints(&self) -> ToolHints {
594 ToolHints::default()
595 .with_readonly(true)
596 .with_open_world(true)
597 .with_long_running(true)
598 }
599
600 async fn execute(&self, arguments: Value) -> ToolExecutionResult {
601 let request = match Self::parse_request(&arguments) {
603 Ok(mut req) => {
604 req.save_to_file = None; req
606 }
607 Err(e) => return e,
608 };
609
610 if let Some(blocked) = self.system_policy_block(&request.url) {
612 return blocked;
613 }
614
615 match self.fetchkit_tool.execute(request).await {
616 Ok(response) => {
617 ToolExecutionResult::success(serde_json::to_value(&response).unwrap_or_else(
618 |_| serde_json::json!({"error": "Failed to serialize response"}),
619 ))
620 }
621 Err(e) => Self::map_error(e),
622 }
623 }
624
625 async fn execute_with_context(
626 &self,
627 arguments: Value,
628 context: &ToolContext,
629 ) -> ToolExecutionResult {
630 let request = match Self::parse_request(&arguments) {
631 Ok(req) => req,
632 Err(e) => return e,
633 };
634
635 if request.save_to_file.is_some() && !self.enable_save_to_file {
636 return ToolExecutionResult::tool_error(
637 "File download is disabled for this capability",
638 );
639 }
640
641 if let Some(blocked) = self.system_policy_block(&request.url) {
645 return blocked;
646 }
647
648 if let Some(ref acl) = context.network_access
652 && !acl.is_url_allowed(&request.url)
653 {
654 return ToolExecutionResult::tool_error(format!(
655 "URL blocked by network access policy: {}",
656 request.url
657 ));
658 }
659
660 let routed_tool;
668 let tool = match &context.egress_service {
669 Some(egress) => {
670 let same_host_redirects_only = self.system_allowlist.is_some();
676 routed_tool = self
677 .builder
678 .clone()
679 .same_host_redirects_only_if_set(same_host_redirects_only.then_some(true))
680 .transport(Arc::new(egress_transport::EgressHttpTransport::new(
681 egress.clone(),
682 context.network_access.clone(),
683 )))
684 .build();
685 &routed_tool
686 }
687 None => &self.fetchkit_tool,
688 };
689
690 if request.save_to_file.is_none() {
692 return match tool.execute(request).await {
693 Ok(response) => {
694 ToolExecutionResult::success(serde_json::to_value(&response).unwrap_or_else(
695 |_| serde_json::json!({"error": "Failed to serialize response"}),
696 ))
697 }
698 Err(e) => Self::map_error(e),
699 };
700 }
701
702 let file_store = match &context.file_store {
704 Some(store) => store.clone(),
705 None => {
706 return ToolExecutionResult::tool_error(
707 "File system not available in this context",
708 );
709 }
710 };
711
712 let saver = SessionFileSaver {
713 file_store,
714 session_id: context.session_id,
715 };
716
717 match tool.execute_with_saver(request, Some(&saver)).await {
718 Ok(response) => {
719 ToolExecutionResult::success(serde_json::to_value(&response).unwrap_or_else(
720 |_| serde_json::json!({"error": "Failed to serialize response"}),
721 ))
722 }
723 Err(e) => Self::map_error(e),
724 }
725 }
726}
727
728#[cfg(test)]
729mod tests {
730 use super::*;
731 use crate::typed_id::SessionId;
732 use wiremock::matchers::{method, path};
733 use wiremock::{Mock, MockServer, ResponseTemplate};
734
735 fn tool_for_wiremock() -> WebFetchTool {
738 let builder = fetchkit::Tool::builder()
739 .enable_save_to_file(true)
740 .enable_render_rakers(true)
741 .block_private_ips(false);
742 let fetchkit_tool = builder.build();
743 let description = fetchkit_tool.description().to_string();
744 WebFetchTool {
745 builder,
746 fetchkit_tool,
747 enable_save_to_file: true,
748 description,
749 system_allowlist: None,
750 }
751 }
752
753 #[tokio::test]
754 async fn system_allowlist_blocks_with_clear_system_policy_error() {
755 use crate::system_allowlist::SystemAllowlist;
756
757 let mut tool = tool_for_wiremock();
758 tool.system_allowlist = Some(
759 SystemAllowlist::from_toml("[groups.test]\nallowed = [\"allowed.example.com\"]\n")
760 .map(Arc::new)
761 .unwrap(),
762 );
763
764 let result = tool
765 .execute(serde_json::json!({ "url": "https://blocked.example.com/path" }))
766 .await;
767
768 let message = match result {
769 ToolExecutionResult::ToolError(message) => message,
770 other => panic!("blocked URL should be a tool error, got: {other:?}"),
771 };
772 assert!(
773 message.contains("blocked by system policy"),
774 "error should name the system policy, got: {message}"
775 );
776 assert!(
777 message.contains("blocked.example.com"),
778 "error should include the URL, got: {message}"
779 );
780 }
781
782 #[tokio::test]
783 async fn legacy_path_system_policy_error_wins_when_both_policies_deny() {
784 use crate::system_allowlist::SystemAllowlist;
785
786 let mut tool = tool_for_wiremock();
787 tool.system_allowlist = Some(
788 SystemAllowlist::from_toml("[groups.test]\nallowed = [\"allowed.example.com\"]\n")
789 .map(Arc::new)
790 .unwrap(),
791 );
792 let mut context = ToolContext::new(SessionId::new());
794 context.network_access = Some(crate::network_access::NetworkAccessList::allow_only([
795 "allowed.example.com",
796 ]));
797
798 let result = tool
799 .execute_with_context(
800 serde_json::json!({ "url": "https://blocked.example.com/x" }),
801 &context,
802 )
803 .await;
804
805 assert!(
806 matches!(
807 &result,
808 ToolExecutionResult::ToolError(msg) if msg.contains("blocked by system policy")
809 ),
810 "operator-level system policy error should take precedence, got: {result:?}"
811 );
812 }
813
814 #[test]
815 fn test_derive_bot_auth_public_key() {
816 let seed = "QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUE";
818 let pk = super::derive_bot_auth_public_key(seed).unwrap();
819
820 assert_eq!(pk.jwk["kty"], "OKP");
822 assert_eq!(pk.jwk["crv"], "Ed25519");
823 assert!(pk.jwk["x"].is_string());
824
825 let fetchkit_config = fetchkit::BotAuthConfig::from_base64_seed(seed).unwrap();
827 assert_eq!(pk.key_id, fetchkit_config.keyid());
828 }
829
830 #[test]
831 fn test_derive_bot_auth_public_key_invalid_seed() {
832 assert!(super::derive_bot_auth_public_key("tooshort").is_none());
833 assert!(super::derive_bot_auth_public_key("!!!invalid!!!").is_none());
834 }
835
836 #[test]
837 fn test_web_fetch_tool_parameters() {
838 let tool = WebFetchTool::default();
839 let schema = tool.parameters_schema();
840
841 assert_eq!(schema["type"], "object");
842 assert!(schema["properties"]["url"].is_object());
843 assert!(schema["properties"]["method"].is_object());
844 assert!(schema["properties"]["as_markdown"].is_object());
845 assert!(schema["properties"]["as_text"].is_object());
846 assert!(schema["properties"]["content_focus"].is_object());
847 assert!(schema["properties"]["crawl"].is_object());
848 assert!(schema["properties"]["max_pages"].is_object());
849 assert!(schema["properties"]["render"].is_object());
850 assert_eq!(schema["required"], serde_json::json!(["url"]));
851 }
852
853 #[test]
854 fn test_web_fetch_capability_metadata() {
855 let cap = WebFetchCapability::new(None);
856
857 assert_eq!(cap.id(), "web_fetch");
858 assert_eq!(cap.name(), "Web Fetch");
859 assert_eq!(cap.status(), CapabilityStatus::Available);
860 assert_eq!(cap.risk_level(), RiskLevel::High);
861 assert_eq!(cap.icon(), Some("globe"));
862 assert_eq!(cap.category(), Some("Network"));
863 assert!(cap.system_prompt_addition().is_none());
865 let preview = cap.system_prompt_preview().unwrap();
867 assert!(preview.contains("web_fetch"));
868 }
869
870 #[test]
871 fn test_web_fetch_capability_has_tool() {
872 let cap = WebFetchCapability::new(None);
873 let tools = cap.tools();
874
875 assert_eq!(tools.len(), 1);
876 assert_eq!(tools[0].name(), "web_fetch");
877 }
878
879 #[tokio::test]
880 async fn test_web_fetch_missing_url() {
881 let tool = WebFetchTool::default();
882 let result = tool.execute(serde_json::json!({})).await;
883
884 if let ToolExecutionResult::ToolError(msg) = result {
885 assert!(msg.contains("url"));
886 } else {
887 panic!("Expected tool error for missing URL");
888 }
889 }
890
891 #[tokio::test]
892 async fn test_web_fetch_invalid_url() {
893 let tool = WebFetchTool::default();
894 let result = tool
895 .execute(serde_json::json!({"url": "not-a-valid-url"}))
896 .await;
897
898 if let ToolExecutionResult::ToolError(msg) = result {
899 assert!(msg.contains("Invalid URL"));
900 } else {
901 panic!("Expected tool error for invalid URL");
902 }
903 }
904
905 #[tokio::test]
906 async fn test_web_fetch_invalid_method() {
907 let tool = WebFetchTool::default();
908 let result = tool
909 .execute(serde_json::json!({"url": "https://example.com", "method": "POST"}))
910 .await;
911
912 if let ToolExecutionResult::ToolError(msg) = result {
913 assert!(msg.contains("Invalid method"));
914 } else {
915 panic!("Expected tool error for invalid method");
916 }
917 }
918
919 #[tokio::test]
921 async fn test_web_fetch_real_request() {
922 let mock_server = MockServer::start().await;
923
924 Mock::given(method("GET"))
925 .and(path("/html"))
926 .respond_with(
927 ResponseTemplate::new(200)
928 .set_body_string("<html><body><p>Herman Melville - Moby Dick</p></body></html>")
929 .insert_header("content-type", "text/html"),
930 )
931 .mount(&mock_server)
932 .await;
933
934 let tool = tool_for_wiremock();
935 let result = tool
936 .execute(serde_json::json!({
937 "url": format!("{}/html", mock_server.uri()),
938 "as_text": true
939 }))
940 .await;
941
942 if let ToolExecutionResult::Success(value) = result {
943 assert_eq!(value["status_code"], 200);
944 assert!(
945 value["content"]
946 .as_str()
947 .unwrap()
948 .contains("Herman Melville")
949 );
950 } else {
951 panic!("Expected successful response");
952 }
953 }
954
955 #[tokio::test]
956 async fn test_web_fetch_head_request() {
957 let mock_server = MockServer::start().await;
958
959 Mock::given(method("HEAD"))
960 .and(path("/html"))
961 .respond_with(
962 ResponseTemplate::new(200)
963 .insert_header("content-type", "text/html")
964 .insert_header("content-length", "100"),
965 )
966 .mount(&mock_server)
967 .await;
968
969 let tool = tool_for_wiremock();
970 let result = tool
971 .execute(serde_json::json!({
972 "url": format!("{}/html", mock_server.uri()),
973 "method": "HEAD"
974 }))
975 .await;
976
977 if let ToolExecutionResult::Success(value) = result {
978 assert_eq!(value["status_code"], 200);
979 assert_eq!(value["method"], "HEAD");
980 assert!(value.get("content").is_none() || value["content"].is_null());
982 } else {
983 panic!("Expected successful response");
984 }
985 }
986
987 #[tokio::test]
988 async fn test_web_fetch_response_includes_size() {
989 let mock_server = MockServer::start().await;
990 let body = "<html><body>Test content</body></html>";
991
992 Mock::given(method("GET"))
993 .and(path("/html"))
994 .respond_with(
995 ResponseTemplate::new(200)
996 .set_body_string(body)
997 .insert_header("content-type", "text/html"),
998 )
999 .mount(&mock_server)
1000 .await;
1001
1002 let tool = tool_for_wiremock();
1003 let result = tool
1004 .execute(serde_json::json!({
1005 "url": format!("{}/html", mock_server.uri())
1006 }))
1007 .await;
1008
1009 if let ToolExecutionResult::Success(value) = result {
1010 assert_eq!(value["status_code"], 200);
1011 assert!(value["size"].as_u64().unwrap() > 0);
1013 } else {
1014 panic!("Expected successful response");
1015 }
1016 }
1017
1018 #[tokio::test]
1019 async fn test_web_fetch_binary_returns_metadata() {
1020 let mock_server = MockServer::start().await;
1021
1022 Mock::given(method("GET"))
1024 .and(path("/image/png"))
1025 .respond_with(
1026 ResponseTemplate::new(200)
1027 .set_body_bytes(vec![0x89, 0x50, 0x4E, 0x47]) .insert_header("content-type", "image/png")
1029 .insert_header("content-length", "4"),
1030 )
1031 .mount(&mock_server)
1032 .await;
1033
1034 let tool = tool_for_wiremock();
1035 let result = tool
1036 .execute(serde_json::json!({
1037 "url": format!("{}/image/png", mock_server.uri())
1038 }))
1039 .await;
1040
1041 if let ToolExecutionResult::Success(value) = result {
1043 assert_eq!(value["status_code"], 200);
1044 assert!(
1045 value["content_type"]
1046 .as_str()
1047 .unwrap()
1048 .contains("image/png")
1049 );
1050 assert!(
1051 value["error"].as_str().unwrap().contains("Binary content")
1052 || value["error"].as_str().unwrap().contains("binary")
1053 );
1054 assert!(value.get("size").is_some() || value["size"].is_null());
1056 } else {
1057 panic!("Expected success response with metadata for binary content");
1058 }
1059 }
1060
1061 #[tokio::test]
1062 async fn test_web_fetch_truncated_field() {
1063 let mock_server = MockServer::start().await;
1064
1065 Mock::given(method("GET"))
1066 .and(path("/html"))
1067 .respond_with(
1068 ResponseTemplate::new(200)
1069 .set_body_string("<html><body>Short content</body></html>")
1070 .insert_header("content-type", "text/html"),
1071 )
1072 .mount(&mock_server)
1073 .await;
1074
1075 let tool = tool_for_wiremock();
1077 let result = tool
1078 .execute(serde_json::json!({
1079 "url": format!("{}/html", mock_server.uri())
1080 }))
1081 .await;
1082
1083 if let ToolExecutionResult::Success(value) = result {
1084 assert!(
1086 value["truncated"].is_null()
1087 || value["truncated"] == false
1088 || value.get("truncated").is_none()
1089 );
1090 } else {
1091 panic!("Expected successful response");
1092 }
1093 }
1094
1095 #[tokio::test]
1096 async fn test_web_fetch_timeout_unreachable_host() {
1097 let mock_server = MockServer::start().await;
1101
1102 Mock::given(method("GET"))
1104 .and(path("/slow"))
1105 .respond_with(
1106 ResponseTemplate::new(200)
1107 .set_body_string("slow response")
1108 .set_delay(std::time::Duration::from_secs(5)),
1109 )
1110 .mount(&mock_server)
1111 .await;
1112
1113 let tool = tool_for_wiremock();
1114 let result = tool
1115 .execute(serde_json::json!({
1116 "url": format!("{}/slow", mock_server.uri())
1117 }))
1118 .await;
1119
1120 match result {
1121 ToolExecutionResult::ToolError(msg) => {
1122 assert!(
1123 msg.contains("timed out") || msg.contains("connect") || msg.contains("failed"),
1124 "Expected timeout or connection error, got: {}",
1125 msg
1126 );
1127 }
1128 _ => {
1129 }
1131 }
1132 }
1133
1134 #[tokio::test]
1135 async fn test_web_fetch_response_has_all_expected_fields() {
1136 let mock_server = MockServer::start().await;
1137
1138 Mock::given(method("GET"))
1139 .and(path("/html"))
1140 .respond_with(
1141 ResponseTemplate::new(200)
1142 .set_body_string("<html><body>Test</body></html>")
1143 .insert_header("content-type", "text/html"),
1144 )
1145 .mount(&mock_server)
1146 .await;
1147
1148 let tool = tool_for_wiremock();
1149 let result = tool
1150 .execute(serde_json::json!({
1151 "url": format!("{}/html", mock_server.uri())
1152 }))
1153 .await;
1154
1155 if let ToolExecutionResult::Success(value) = result {
1156 assert!(value.get("url").is_some(), "Missing 'url' field");
1158 assert!(
1159 value.get("status_code").is_some(),
1160 "Missing 'status_code' field"
1161 );
1162 assert!(
1163 value.get("content_type").is_some(),
1164 "Missing 'content_type' field"
1165 );
1166 assert!(value.get("size").is_some(), "Missing 'size' field");
1167 } else {
1169 panic!("Expected successful response");
1170 }
1171 }
1172
1173 #[tokio::test]
1174 async fn test_web_fetch_head_response_structure() {
1175 let mock_server = MockServer::start().await;
1176
1177 Mock::given(method("HEAD"))
1178 .and(path("/html"))
1179 .respond_with(
1180 ResponseTemplate::new(200)
1181 .insert_header("content-type", "text/html")
1182 .insert_header("content-length", "100"),
1183 )
1184 .mount(&mock_server)
1185 .await;
1186
1187 let tool = tool_for_wiremock();
1188 let result = tool
1189 .execute(serde_json::json!({
1190 "url": format!("{}/html", mock_server.uri()),
1191 "method": "HEAD"
1192 }))
1193 .await;
1194
1195 if let ToolExecutionResult::Success(value) = result {
1196 assert!(value.get("url").is_some());
1198 assert!(value.get("status_code").is_some());
1199 assert!(value.get("method").is_some());
1200 assert_eq!(value["method"], "HEAD");
1201 assert!(value.get("content").is_none() || value["content"].is_null());
1203 } else {
1204 panic!("Expected successful response");
1205 }
1206 }
1207
1208 #[tokio::test]
1209 async fn test_web_fetch_html_returns_markdown_by_default() {
1210 let mock_server = MockServer::start().await;
1211
1212 Mock::given(method("GET"))
1213 .and(path("/html"))
1214 .respond_with(
1215 ResponseTemplate::new(200)
1216 .set_body_string(
1217 "<!DOCTYPE html><html><body><h1>Title</h1><p>Content</p></body></html>",
1218 )
1219 .insert_header("content-type", "text/html"),
1220 )
1221 .mount(&mock_server)
1222 .await;
1223
1224 let tool = tool_for_wiremock();
1225 let result = tool
1227 .execute(serde_json::json!({
1228 "url": format!("{}/html", mock_server.uri())
1229 }))
1230 .await;
1231
1232 if let ToolExecutionResult::Success(value) = result {
1233 assert_eq!(value["status_code"], 200);
1234 let content = value["content"].as_str().unwrap();
1236 assert!(content.contains("Title") || content.contains("Content"));
1237 let format = value["format"].as_str().unwrap_or("raw");
1239 assert!(format == "markdown" || format == "raw");
1240 } else {
1241 panic!("Expected successful response");
1242 }
1243 }
1244
1245 #[tokio::test]
1246 async fn test_web_fetch_renders_inline_javascript_when_requested() {
1247 let mock_server = MockServer::start().await;
1248 let html = r#"<!doctype html>
1249 <html><body>
1250 <div id="app">Loading</div>
1251 <script>
1252 document.body.innerHTML = '<main><h1>Rendered Inline</h1><p>Ready</p></main>';
1253 </script>
1254 </body></html>"#;
1255
1256 Mock::given(method("GET"))
1257 .and(path("/spa"))
1258 .respond_with(ResponseTemplate::new(200).set_body_raw(html, "text/html"))
1259 .mount(&mock_server)
1260 .await;
1261
1262 let result = tool_for_wiremock()
1263 .execute(serde_json::json!({
1264 "url": format!("{}/spa", mock_server.uri()),
1265 "render": "rakers"
1266 }))
1267 .await;
1268
1269 if let ToolExecutionResult::Success(value) = result {
1270 assert_eq!(value["rendered_by"], "rakers");
1271 let content = value["content"].as_str().unwrap();
1272 assert!(content.contains("Rendered Inline"));
1273 assert!(content.contains("Ready"));
1274 assert!(!content.contains("Loading"));
1275 } else {
1276 panic!("Expected rendered response, got: {result:?}");
1277 }
1278 }
1279
1280 #[tokio::test]
1281 async fn test_web_fetch_as_text_strips_html() {
1282 let mock_server = MockServer::start().await;
1283
1284 Mock::given(method("GET"))
1285 .and(path("/html"))
1286 .respond_with(
1287 ResponseTemplate::new(200)
1288 .set_body_string("<!DOCTYPE html><html><body><b>Test</b> content</body></html>")
1289 .insert_header("content-type", "text/html"),
1290 )
1291 .mount(&mock_server)
1292 .await;
1293
1294 let tool = tool_for_wiremock();
1295 let result = tool
1296 .execute(serde_json::json!({
1297 "url": format!("{}/html", mock_server.uri()),
1298 "as_text": true
1299 }))
1300 .await;
1301
1302 if let ToolExecutionResult::Success(value) = result {
1303 assert_eq!(value["status_code"], 200);
1304 let content = value["content"].as_str().unwrap();
1306 assert!(content.contains("Test") || content.contains("content"));
1307 let format = value["format"].as_str().unwrap_or("raw");
1309 assert!(format == "text" || format == "raw");
1310 } else {
1311 panic!("Expected successful response");
1312 }
1313 }
1314
1315 #[tokio::test]
1316 async fn test_web_fetch_raw_format_for_non_html() {
1317 let mock_server = MockServer::start().await;
1318
1319 Mock::given(method("GET"))
1320 .and(path("/json"))
1321 .respond_with(
1322 ResponseTemplate::new(200)
1323 .set_body_string("{\"key\": \"value\"}")
1324 .insert_header("content-type", "application/json"),
1325 )
1326 .mount(&mock_server)
1327 .await;
1328
1329 let tool = tool_for_wiremock();
1330 let result = tool
1331 .execute(serde_json::json!({
1332 "url": format!("{}/json", mock_server.uri())
1333 }))
1334 .await;
1335
1336 if let ToolExecutionResult::Success(value) = result {
1337 assert_eq!(value["format"], "raw");
1339 } else {
1340 panic!("Expected successful response");
1341 }
1342 }
1343
1344 #[tokio::test]
1345 async fn test_web_fetch_404_returns_success_with_status() {
1346 let mock_server = MockServer::start().await;
1347
1348 Mock::given(method("GET"))
1349 .and(path("/status/404"))
1350 .respond_with(ResponseTemplate::new(404).set_body_string("Not Found"))
1351 .mount(&mock_server)
1352 .await;
1353
1354 let tool = tool_for_wiremock();
1355 let result = tool
1356 .execute(serde_json::json!({
1357 "url": format!("{}/status/404", mock_server.uri())
1358 }))
1359 .await;
1360
1361 if let ToolExecutionResult::Success(value) = result {
1363 assert_eq!(value["status_code"], 404);
1364 } else {
1365 panic!("Expected successful response even for 404");
1366 }
1367 }
1368
1369 #[tokio::test]
1370 async fn test_web_fetch_500_returns_success_with_status() {
1371 let mock_server = MockServer::start().await;
1372
1373 Mock::given(method("GET"))
1374 .and(path("/status/500"))
1375 .respond_with(ResponseTemplate::new(500).set_body_string("Internal Server Error"))
1376 .mount(&mock_server)
1377 .await;
1378
1379 let tool = tool_for_wiremock();
1380 let result = tool
1381 .execute(serde_json::json!({
1382 "url": format!("{}/status/500", mock_server.uri())
1383 }))
1384 .await;
1385
1386 if let ToolExecutionResult::Success(value) = result {
1388 assert_eq!(value["status_code"], 500);
1389 } else {
1390 panic!("Expected successful response even for 500");
1391 }
1392 }
1393
1394 #[tokio::test]
1395 async fn test_web_fetch_dns_failure() {
1396 let tool = WebFetchTool::default();
1397 let result = tool
1398 .execute(serde_json::json!({
1399 "url": "https://this-domain-definitely-does-not-exist-12345.com/test"
1400 }))
1401 .await;
1402
1403 if let ToolExecutionResult::ToolError(msg) = result {
1407 let msg_lower = msg.to_lowercase();
1408 assert!(
1409 msg_lower.contains("failed")
1410 || msg_lower.contains("error")
1411 || msg_lower.contains("timed out")
1412 || msg_lower.contains("connect")
1413 || msg_lower.contains("blocked"),
1414 "Expected error message about failure, got: {}",
1415 msg
1416 );
1417 } else {
1418 }
1420 }
1421
1422 #[tokio::test]
1423 async fn test_web_fetch_rejects_ftp_url() {
1424 let tool = WebFetchTool::default();
1425 let result = tool
1426 .execute(serde_json::json!({
1427 "url": "ftp://example.com/file.txt"
1428 }))
1429 .await;
1430
1431 if let ToolExecutionResult::ToolError(msg) = result {
1432 assert!(msg.contains("Invalid URL"));
1433 } else {
1434 panic!("Expected tool error for FTP URL");
1435 }
1436 }
1437
1438 #[tokio::test]
1439 async fn test_web_fetch_rejects_file_url() {
1440 let tool = WebFetchTool::default();
1441 let result = tool
1442 .execute(serde_json::json!({
1443 "url": "file:///etc/passwd"
1444 }))
1445 .await;
1446
1447 if let ToolExecutionResult::ToolError(msg) = result {
1448 assert!(msg.contains("Invalid URL"));
1449 } else {
1450 panic!("Expected tool error for file:// URL");
1451 }
1452 }
1453
1454 #[tokio::test]
1455 async fn test_web_fetch_accepts_http_url() {
1456 let mock_server = MockServer::start().await;
1457
1458 Mock::given(method("GET"))
1459 .and(path("/get"))
1460 .respond_with(
1461 ResponseTemplate::new(200)
1462 .set_body_string("{\"url\": \"http://localhost/get\"}")
1463 .insert_header("content-type", "application/json"),
1464 )
1465 .mount(&mock_server)
1466 .await;
1467
1468 let tool = tool_for_wiremock();
1469 let result = tool
1471 .execute(serde_json::json!({
1472 "url": format!("{}/get", mock_server.uri())
1473 }))
1474 .await;
1475
1476 if let ToolExecutionResult::Success(value) = result {
1478 assert_eq!(value["status_code"], 200);
1479 } else {
1480 panic!("Expected successful response for HTTP URL");
1481 }
1482 }
1483
1484 #[tokio::test]
1485 async fn test_web_fetch_filters_excessive_newlines() {
1486 let mock_server = MockServer::start().await;
1487
1488 Mock::given(method("GET"))
1490 .and(path("/newlines"))
1491 .respond_with(
1492 ResponseTemplate::new(200)
1493 .set_body_string("line1\n\n\n\n\n\n\n\nline2")
1494 .insert_header("content-type", "text/plain"),
1495 )
1496 .mount(&mock_server)
1497 .await;
1498
1499 let tool = tool_for_wiremock();
1500 let result = tool
1501 .execute(serde_json::json!({
1502 "url": format!("{}/newlines", mock_server.uri())
1503 }))
1504 .await;
1505
1506 if let ToolExecutionResult::Success(value) = result {
1507 let content = value["content"].as_str().unwrap();
1508 assert!(
1510 !content.contains("\n\n\n"),
1511 "Content should not have more than 2 consecutive newlines"
1512 );
1513 } else {
1514 panic!("Expected successful response");
1515 }
1516 }
1517
1518 async fn assert_blocked_by_policy(url: &str) {
1532 let tool = WebFetchTool::default();
1533 let result = tool.execute(serde_json::json!({"url": url})).await;
1534 assert!(
1535 matches!(&result, ToolExecutionResult::ToolError(msg) if msg.contains("blocked")),
1536 "Expected URL {url} to be blocked by policy, got: {:?}",
1537 result
1538 );
1539 }
1540
1541 #[tokio::test]
1543 async fn test_ssrf_cloud_metadata_blocked() {
1544 assert_blocked_by_policy("http://169.254.169.254/latest/meta-data/").await;
1545 }
1546
1547 #[tokio::test]
1549 async fn test_ssrf_localhost_blocked() {
1550 assert_blocked_by_policy("http://127.0.0.1:1/").await;
1551 }
1552
1553 #[tokio::test]
1555 async fn test_ssrf_private_10_blocked() {
1556 assert_blocked_by_policy("http://10.0.0.1:1/").await;
1557 }
1558
1559 #[tokio::test]
1561 async fn test_ssrf_private_172_blocked() {
1562 assert_blocked_by_policy("http://172.16.0.1:1/").await;
1563 }
1564
1565 #[tokio::test]
1567 async fn test_ssrf_private_192_blocked() {
1568 assert_blocked_by_policy("http://192.168.0.1:1/").await;
1569 }
1570
1571 #[tokio::test]
1573 async fn test_ssrf_ipv6_localhost_blocked() {
1574 assert_blocked_by_policy("http://[::1]:1/").await;
1575 }
1576
1577 #[tokio::test]
1579 async fn test_ssrf_unspecified_blocked() {
1580 assert_blocked_by_policy("http://0.0.0.0:1/").await;
1581 }
1582
1583 #[tokio::test]
1585 async fn test_ssrf_non_http_schemes_blocked() {
1586 let tool = WebFetchTool::default();
1587
1588 for (scheme, url) in [
1589 ("file://", "file:///etc/passwd"),
1590 ("ftp://", "ftp://internal-server/data"),
1591 ("gopher://", "gopher://internal-server/"),
1592 ] {
1593 let result = tool.execute(serde_json::json!({"url": url})).await;
1594 assert!(
1595 matches!(&result, ToolExecutionResult::ToolError(msg) if msg.contains("Invalid URL")),
1596 "{scheme} should be rejected"
1597 );
1598 }
1599 }
1600
1601 #[tokio::test]
1606 async fn test_fetch_html_page() {
1607 let mock_server = MockServer::start().await;
1608 let html = r#"<html><head><title>Wasmtime Docs</title></head>
1609 <body><h1>Wasmtime</h1><p>A fast and secure runtime for WebAssembly.</p>
1610 <p>Wasmtime is a standalone runtime for WebAssembly that can be used
1611 as a CLI tool or embedded into other systems.</p></body></html>"#;
1612
1613 Mock::given(method("GET"))
1614 .and(path("/"))
1615 .respond_with(
1616 ResponseTemplate::new(200)
1617 .set_body_string(html)
1618 .insert_header("content-type", "text/html; charset=utf-8"),
1619 )
1620 .mount(&mock_server)
1621 .await;
1622
1623 let tool = tool_for_wiremock();
1624 let result = tool
1625 .execute(serde_json::json!({
1626 "url": format!("{}/", mock_server.uri())
1627 }))
1628 .await;
1629
1630 if let ToolExecutionResult::Success(value) = result {
1631 assert_eq!(value["status_code"], 200);
1632 let content = value["content"].as_str().unwrap();
1633 assert!(
1634 content.contains("Wasmtime") || content.contains("wasmtime"),
1635 "Content should mention Wasmtime"
1636 );
1637 assert!(
1638 value["size"].as_u64().unwrap() > 100,
1639 "Page should have substantial content"
1640 );
1641 } else {
1642 panic!("Expected successful response, got: {:?}", result);
1643 }
1644 }
1645
1646 #[tokio::test]
1647 async fn test_fetch_html_as_text() {
1648 let mock_server = MockServer::start().await;
1649 let html = r#"<html><head><title>Wasmtime Docs</title></head>
1650 <body><h1>Wasmtime</h1><p>A fast and secure runtime.</p></body></html>"#;
1651
1652 Mock::given(method("GET"))
1653 .and(path("/"))
1654 .respond_with(
1655 ResponseTemplate::new(200)
1656 .set_body_string(html)
1657 .insert_header("content-type", "text/html"),
1658 )
1659 .mount(&mock_server)
1660 .await;
1661
1662 let tool = tool_for_wiremock();
1663 let result = tool
1664 .execute(serde_json::json!({
1665 "url": format!("{}/", mock_server.uri()),
1666 "as_text": true
1667 }))
1668 .await;
1669
1670 if let ToolExecutionResult::Success(value) = result {
1671 assert_eq!(value["status_code"], 200);
1672 let content = value["content"].as_str().unwrap();
1673 assert!(
1674 content.contains("Wasmtime") || content.contains("wasmtime"),
1675 "Text should contain Wasmtime reference"
1676 );
1677 let format = value["format"].as_str().unwrap_or("raw");
1678 assert!(
1679 format == "text" || format == "raw",
1680 "Format should be text or raw, got: {}",
1681 format
1682 );
1683 } else {
1684 panic!(
1685 "Expected successful response with text conversion, got: {:?}",
1686 result
1687 );
1688 }
1689 }
1690
1691 #[tokio::test]
1692 async fn test_fetch_head_request() {
1693 let mock_server = MockServer::start().await;
1694
1695 Mock::given(method("HEAD"))
1696 .and(path("/"))
1697 .respond_with(
1698 ResponseTemplate::new(200)
1699 .insert_header("content-type", "text/html; charset=utf-8")
1700 .insert_header("content-length", "5000"),
1701 )
1702 .mount(&mock_server)
1703 .await;
1704
1705 let tool = tool_for_wiremock();
1706 let result = tool
1707 .execute(serde_json::json!({
1708 "url": format!("{}/", mock_server.uri()),
1709 "method": "HEAD"
1710 }))
1711 .await;
1712
1713 if let ToolExecutionResult::Success(value) = result {
1714 assert_eq!(value["status_code"], 200);
1715 assert_eq!(value["method"], "HEAD");
1716 assert!(
1717 value["content"].is_null()
1718 || value["content"].as_str().is_none_or(|s| s.is_empty()),
1719 "HEAD request should not return content body"
1720 );
1721 assert!(value["content_type"].as_str().is_some());
1722 } else {
1723 panic!("Expected successful HEAD response, got: {:?}", result);
1724 }
1725 }
1726
1727 #[tokio::test]
1728 async fn test_fetch_subpage() {
1729 let mock_server = MockServer::start().await;
1730 let body = format!(
1732 "<html><body><h1>Introduction</h1><p>{}</p></body></html>",
1733 "WebAssembly is a portable binary instruction format. ".repeat(20)
1734 );
1735
1736 Mock::given(method("GET"))
1737 .and(path("/introduction.html"))
1738 .respond_with(
1739 ResponseTemplate::new(200)
1740 .set_body_string(&body)
1741 .insert_header("content-type", "text/html"),
1742 )
1743 .mount(&mock_server)
1744 .await;
1745
1746 let tool = tool_for_wiremock();
1747 let result = tool
1748 .execute(serde_json::json!({
1749 "url": format!("{}/introduction.html", mock_server.uri())
1750 }))
1751 .await;
1752
1753 if let ToolExecutionResult::Success(value) = result {
1754 assert_eq!(value["status_code"], 200);
1755 let content = value["content"].as_str().unwrap();
1756 assert!(
1757 content.len() > 500,
1758 "Subpage should have substantial content, got {} bytes",
1759 content.len()
1760 );
1761 } else {
1762 panic!(
1763 "Expected successful response from subpage, got: {:?}",
1764 result
1765 );
1766 }
1767 }
1768
1769 #[tokio::test]
1770 async fn test_fetch_repo_page() {
1771 let mock_server = MockServer::start().await;
1772 let html = r#"<html><body>
1773 <h1>wasm3/wasm3</h1>
1774 <p>The fastest WebAssembly interpreter (and target for wasm3).</p>
1775 <div class="readme"><h2>README</h2><p>wasm3 is a high performance
1776 WebAssembly interpreter written in C.</p></div>
1777 </body></html>"#;
1778
1779 Mock::given(method("GET"))
1780 .and(path("/wasm3/wasm3"))
1781 .respond_with(
1782 ResponseTemplate::new(200)
1783 .set_body_string(html)
1784 .insert_header("content-type", "text/html; charset=utf-8"),
1785 )
1786 .mount(&mock_server)
1787 .await;
1788
1789 let tool = tool_for_wiremock();
1790 let result = tool
1791 .execute(serde_json::json!({
1792 "url": format!("{}/wasm3/wasm3", mock_server.uri())
1793 }))
1794 .await;
1795
1796 if let ToolExecutionResult::Success(value) = result {
1797 assert_eq!(value["status_code"], 200);
1798 let content = value["content"].as_str().unwrap();
1799 assert!(
1800 content.to_lowercase().contains("wasm3"),
1801 "Content should mention wasm3"
1802 );
1803 } else {
1804 panic!("Expected successful response, got: {:?}", result);
1805 }
1806 }
1807
1808 #[tokio::test]
1809 async fn test_fetch_repo_page_as_text() {
1810 let mock_server = MockServer::start().await;
1811 let html = r#"<html><body>
1812 <h1>wasm3/wasm3</h1>
1813 <p>The fastest WebAssembly interpreter written in C.</p>
1814 </body></html>"#;
1815
1816 Mock::given(method("GET"))
1817 .and(path("/wasm3/wasm3"))
1818 .respond_with(
1819 ResponseTemplate::new(200)
1820 .set_body_string(html)
1821 .insert_header("content-type", "text/html; charset=utf-8"),
1822 )
1823 .mount(&mock_server)
1824 .await;
1825
1826 let tool = tool_for_wiremock();
1827 let result = tool
1828 .execute(serde_json::json!({
1829 "url": format!("{}/wasm3/wasm3", mock_server.uri()),
1830 "as_text": true
1831 }))
1832 .await;
1833
1834 if let ToolExecutionResult::Success(value) = result {
1835 assert_eq!(value["status_code"], 200);
1836 let content = value["content"].as_str().unwrap();
1837 assert!(
1838 content.to_lowercase().contains("wasm3"),
1839 "Content should mention wasm3"
1840 );
1841 } else {
1842 panic!("Expected successful response, got: {:?}", result);
1843 }
1844 }
1845
1846 struct MockFileStore {
1852 files: tokio::sync::Mutex<std::collections::HashMap<(SessionId, String), (String, String)>>,
1853 directories: tokio::sync::Mutex<std::collections::HashSet<(SessionId, String)>>,
1854 }
1855
1856 impl MockFileStore {
1857 fn new() -> Self {
1858 Self {
1859 files: tokio::sync::Mutex::new(std::collections::HashMap::new()),
1860 directories: tokio::sync::Mutex::new(std::collections::HashSet::new()),
1861 }
1862 }
1863
1864 async fn add_directory(&self, session_id: SessionId, path: &str) {
1865 self.directories
1866 .lock()
1867 .await
1868 .insert((session_id, path.to_string()));
1869 }
1870
1871 async fn get_file(&self, session_id: SessionId, path: &str) -> Option<(String, String)> {
1872 self.files
1873 .lock()
1874 .await
1875 .get(&(session_id, path.to_string()))
1876 .cloned()
1877 }
1878 }
1879
1880 #[async_trait]
1881 impl SessionFileSystem for MockFileStore {
1882 fn is_mount_resolver(&self) -> bool {
1883 false
1884 }
1885
1886 async fn read_file(
1887 &self,
1888 session_id: SessionId,
1889 path: &str,
1890 ) -> crate::error::Result<Option<crate::session_file::SessionFile>> {
1891 let guard = self.files.lock().await;
1892 if let Some((content, encoding)) = guard.get(&(session_id, path.to_string())) {
1893 Ok(Some(crate::session_file::SessionFile {
1894 id: uuid::Uuid::new_v4(),
1895 session_id: session_id.uuid(),
1896 path: path.to_string(),
1897 name: path.rsplit('/').next().unwrap_or(path).to_string(),
1898 content: Some(content.clone()),
1899 encoding: encoding.clone(),
1900 size_bytes: content.len() as i64,
1901 is_directory: false,
1902 is_readonly: false,
1903 created_at: chrono::Utc::now(),
1904 updated_at: chrono::Utc::now(),
1905 }))
1906 } else {
1907 Ok(None)
1908 }
1909 }
1910
1911 async fn write_file(
1912 &self,
1913 session_id: SessionId,
1914 path: &str,
1915 content: &str,
1916 encoding: &str,
1917 ) -> crate::error::Result<crate::session_file::SessionFile> {
1918 self.files.lock().await.insert(
1919 (session_id, path.to_string()),
1920 (content.to_string(), encoding.to_string()),
1921 );
1922 Ok(crate::session_file::SessionFile {
1923 id: uuid::Uuid::new_v4(),
1924 session_id: session_id.uuid(),
1925 path: path.to_string(),
1926 name: path.rsplit('/').next().unwrap_or(path).to_string(),
1927 content: Some(content.to_string()),
1928 encoding: encoding.to_string(),
1929 size_bytes: content.len() as i64,
1930 is_directory: false,
1931 is_readonly: false,
1932 created_at: chrono::Utc::now(),
1933 updated_at: chrono::Utc::now(),
1934 })
1935 }
1936
1937 async fn delete_file(
1938 &self,
1939 _session_id: SessionId,
1940 _path: &str,
1941 _recursive: bool,
1942 ) -> crate::error::Result<bool> {
1943 Ok(false)
1944 }
1945
1946 async fn list_directory(
1947 &self,
1948 _session_id: SessionId,
1949 _path: &str,
1950 ) -> crate::error::Result<Vec<crate::session_file::FileInfo>> {
1951 Ok(vec![])
1952 }
1953
1954 async fn stat_file(
1955 &self,
1956 session_id: SessionId,
1957 path: &str,
1958 ) -> crate::error::Result<Option<crate::session_file::FileStat>> {
1959 if self
1960 .directories
1961 .lock()
1962 .await
1963 .contains(&(session_id, path.to_string()))
1964 {
1965 return Ok(Some(crate::session_file::FileStat {
1966 path: path.to_string(),
1967 name: path.rsplit('/').next().unwrap_or(path).to_string(),
1968 is_directory: true,
1969 is_readonly: false,
1970 size_bytes: 0,
1971 created_at: chrono::Utc::now(),
1972 updated_at: chrono::Utc::now(),
1973 }));
1974 }
1975 Ok(None)
1976 }
1977
1978 async fn grep_files(
1979 &self,
1980 _session_id: SessionId,
1981 _pattern: &str,
1982 _path_pattern: Option<&str>,
1983 ) -> crate::error::Result<Vec<crate::session_file::GrepMatch>> {
1984 Ok(vec![])
1985 }
1986
1987 async fn create_directory(
1988 &self,
1989 _session_id: SessionId,
1990 _path: &str,
1991 ) -> crate::error::Result<crate::session_file::FileInfo> {
1992 unimplemented!()
1993 }
1994 }
1995
1996 #[test]
1997 fn test_web_fetch_tool_schema_save_to_file_gated_by_config() {
1998 let tool = WebFetchTool::new(false, None);
2000 let schema = tool.parameters_schema();
2001 assert!(
2002 !schema["properties"]["save_to_file"].is_object(),
2003 "Schema should NOT include save_to_file when disabled"
2004 );
2005
2006 let tool = WebFetchTool::new(true, None);
2008 let schema = tool.parameters_schema();
2009 assert!(
2010 schema["properties"]["save_to_file"].is_object(),
2011 "Schema should include save_to_file when enabled"
2012 );
2013 }
2014
2015 #[test]
2016 fn test_web_fetch_tool_requires_context() {
2017 let tool = WebFetchTool::default();
2018 assert!(tool.requires_context());
2019 }
2020
2021 #[test]
2022 fn test_save_to_file_is_trimmed_and_blank_is_absent() {
2023 let blank = WebFetchTool::parse_request(&serde_json::json!({
2024 "url": "https://example.com",
2025 "save_to_file": " \n\t "
2026 }))
2027 .unwrap();
2028 assert_eq!(blank.save_to_file, None);
2029
2030 let path = WebFetchTool::parse_request(&serde_json::json!({
2031 "url": "https://example.com",
2032 "save_to_file": " /downloads/file.txt "
2033 }))
2034 .unwrap();
2035 assert_eq!(path.save_to_file.as_deref(), Some("/downloads/file.txt"));
2036 }
2037
2038 #[test]
2039 fn test_fetchkit_request_fields_are_forwarded() {
2040 let request = WebFetchTool::parse_request(&serde_json::json!({
2041 "url": "https://example.com/docs",
2042 "method": "get",
2043 "content_focus": "agent",
2044 "crawl": true,
2045 "max_pages": 3,
2046 "if_none_match": "\"abc123\"",
2047 "if_modified_since": "Wed, 15 Jul 2026 12:00:00 GMT",
2048 "render": "rakers"
2049 }))
2050 .unwrap();
2051
2052 assert_eq!(request.content_focus.as_deref(), Some("agent"));
2053 assert_eq!(request.crawl, Some(true));
2054 assert_eq!(request.max_pages, Some(3));
2055 assert_eq!(request.if_none_match.as_deref(), Some("\"abc123\""));
2056 assert_eq!(
2057 request.if_modified_since.as_deref(),
2058 Some("Wed, 15 Jul 2026 12:00:00 GMT")
2059 );
2060 assert_eq!(serde_json::to_value(request).unwrap()["render"], "rakers");
2061 }
2062
2063 #[tokio::test]
2064 async fn test_blank_save_to_file_fetches_inline_without_file_store() {
2065 let tool = WebFetchTool::new(false, None);
2066 let mut context = ToolContext::new(SessionId::new());
2067 context.egress_service = Some(Arc::new(CannedEgress));
2068
2069 let result = tool
2070 .execute_with_context(
2071 serde_json::json!({
2072 "url": "http://93.184.216.34/file.txt",
2073 "save_to_file": " \n "
2074 }),
2075 &context,
2076 )
2077 .await;
2078
2079 let ToolExecutionResult::Success(value) = result else {
2080 panic!("blank save_to_file should fetch inline: {result:?}");
2081 };
2082 assert_eq!(value["content"], "pong from egress");
2083 assert!(value.get("saved_path").is_none() || value["saved_path"].is_null());
2084 }
2085
2086 #[test]
2087 fn test_save_error_remains_distinct_from_http_errors() {
2088 let result = WebFetchTool::map_error(FetchError::SaveError(
2089 "Path not allowed: Destination is an existing directory: /downloads".to_string(),
2090 ));
2091 assert!(matches!(
2092 result,
2093 ToolExecutionResult::ToolError(message)
2094 if message == "Failed to save file: Path not allowed: Destination is an existing directory: /downloads"
2095 ));
2096 }
2097
2098 #[test]
2099 fn test_web_fetch_tools_with_config_enables_file_download() {
2100 let cap = WebFetchCapability::new(None);
2101
2102 let tools = cap.tools_with_config(&serde_json::json!({}));
2104 assert_eq!(tools.len(), 1);
2105 let schema = tools[0].parameters_schema();
2106 assert!(!schema["properties"]["save_to_file"].is_object());
2107
2108 let tools = cap.tools_with_config(&serde_json::json!({"enable_file_download": true}));
2110 assert_eq!(tools.len(), 1);
2111 let schema = tools[0].parameters_schema();
2112 assert!(schema["properties"]["save_to_file"].is_object());
2113 }
2114
2115 #[tokio::test]
2116 async fn test_web_fetch_system_prompt_adapts_to_config() {
2117 let cap = WebFetchCapability::new(None);
2118 let ctx = super::super::SystemPromptContext::without_file_store(SessionId::new());
2119
2120 let prompt = cap
2122 .system_prompt_contribution_with_config(&ctx, &serde_json::json!({}))
2123 .await
2124 .unwrap();
2125 assert!(!prompt.contains("save_to_file"));
2126
2127 let prompt = cap
2129 .system_prompt_contribution_with_config(
2130 &ctx,
2131 &serde_json::json!({"enable_file_download": true}),
2132 )
2133 .await
2134 .unwrap();
2135 assert!(prompt.contains("save_to_file"));
2136 }
2137
2138 #[tokio::test]
2139 async fn test_save_to_file_text_content() {
2140 let mock_server = MockServer::start().await;
2141
2142 Mock::given(method("GET"))
2143 .and(path("/data.json"))
2144 .respond_with(
2145 ResponseTemplate::new(200)
2146 .set_body_string("{\"key\": \"value\"}")
2147 .insert_header("content-type", "application/json"),
2148 )
2149 .mount(&mock_server)
2150 .await;
2151
2152 let tool = tool_for_wiremock();
2153 let file_store = Arc::new(MockFileStore::new());
2154 let session_id = SessionId::new();
2155 let context = ToolContext::with_file_store(session_id, file_store.clone());
2156
2157 let result = tool
2158 .execute_with_context(
2159 serde_json::json!({
2160 "url": format!("{}/data.json", mock_server.uri()),
2161 "save_to_file": "/downloads/data.json"
2162 }),
2163 &context,
2164 )
2165 .await;
2166
2167 if let ToolExecutionResult::Success(value) = result {
2168 assert_eq!(value["status_code"], 200);
2169 assert!(value["saved_path"].as_str().is_some());
2170 assert!(value["bytes_written"].as_u64().unwrap() > 0);
2171 assert!(
2173 value.get("content").is_none() || value["content"].is_null(),
2174 "Content should not be inline when saving to file"
2175 );
2176
2177 let (content, encoding) = file_store
2179 .get_file(session_id, "/downloads/data.json")
2180 .await
2181 .expect("File should have been written");
2182 assert_eq!(encoding, "text");
2183 assert!(content.contains("value"));
2184 } else {
2185 panic!("Expected successful response, got: {:?}", result);
2186 }
2187 }
2188
2189 #[tokio::test]
2190 async fn test_session_file_saver_rejects_workspace_roots_and_directories() {
2191 let file_store = Arc::new(MockFileStore::new());
2192 let session_id = SessionId::new();
2193 file_store.add_directory(session_id, "/downloads").await;
2194 let saver = SessionFileSaver {
2195 file_store,
2196 session_id,
2197 };
2198
2199 for path in ["", " ", "/", "/workspace", "/workspace/"] {
2200 let error = saver.validate_path(path).await.unwrap_err();
2201 assert!(
2202 matches!(error, FileSaveError::PathNotAllowed(_)),
2203 "root destination {path:?} should be a path error: {error}"
2204 );
2205 }
2206
2207 let error = saver.validate_path("/downloads").await.unwrap_err();
2208 assert!(
2209 matches!(error, FileSaveError::PathNotAllowed(ref message) if message.contains("directory")),
2210 "directory destination should be a precise path error: {error}"
2211 );
2212 }
2213
2214 #[tokio::test]
2215 async fn test_session_file_saver_resolves_and_saves_valid_path() {
2216 let file_store = Arc::new(MockFileStore::new());
2217 let session_id = SessionId::new();
2218 let saver = SessionFileSaver {
2219 file_store: file_store.clone(),
2220 session_id,
2221 };
2222
2223 saver.validate_path("downloads/file.txt").await.unwrap();
2224 let saved = saver
2225 .save("downloads/file.txt", b"downloaded")
2226 .await
2227 .unwrap();
2228
2229 assert_eq!(saved.path, "/downloads/file.txt");
2230 assert_eq!(saved.bytes_written, 10);
2231 assert_eq!(
2232 file_store.get_file(session_id, "/downloads/file.txt").await,
2233 Some(("downloaded".to_string(), "text".to_string()))
2234 );
2235 }
2236
2237 #[tokio::test]
2238 async fn test_save_to_file_binary_content() {
2239 let mock_server = MockServer::start().await;
2240
2241 let png_bytes = vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0xFF, 0xFE];
2243 Mock::given(method("GET"))
2244 .and(path("/image.png"))
2245 .respond_with(
2246 ResponseTemplate::new(200)
2247 .set_body_bytes(png_bytes.clone())
2248 .insert_header("content-type", "image/png"),
2249 )
2250 .mount(&mock_server)
2251 .await;
2252
2253 let tool = tool_for_wiremock();
2254 let file_store = Arc::new(MockFileStore::new());
2255 let session_id = SessionId::new();
2256 let context = ToolContext::with_file_store(session_id, file_store.clone());
2257
2258 let result = tool
2259 .execute_with_context(
2260 serde_json::json!({
2261 "url": format!("{}/image.png", mock_server.uri()),
2262 "save_to_file": "/downloads/image.png"
2263 }),
2264 &context,
2265 )
2266 .await;
2267
2268 if let ToolExecutionResult::Success(value) = result {
2269 assert_eq!(value["status_code"], 200);
2270 assert!(value["saved_path"].as_str().is_some());
2271 assert_eq!(
2272 value["bytes_written"].as_u64().unwrap(),
2273 png_bytes.len() as u64
2274 );
2275
2276 let (content, encoding) = file_store
2278 .get_file(session_id, "/downloads/image.png")
2279 .await
2280 .expect("File should have been written");
2281 assert_eq!(encoding, "base64");
2282
2283 let decoded = base64::engine::general_purpose::STANDARD
2285 .decode(&content)
2286 .expect("Should be valid base64");
2287 assert_eq!(decoded, png_bytes);
2288 } else {
2289 panic!("Expected successful response, got: {:?}", result);
2290 }
2291 }
2292
2293 #[tokio::test]
2294 async fn test_save_to_file_no_file_store_returns_error() {
2295 let mock_server = MockServer::start().await;
2296
2297 Mock::given(method("GET"))
2298 .and(path("/file.txt"))
2299 .respond_with(ResponseTemplate::new(200).set_body_string("content"))
2300 .mount(&mock_server)
2301 .await;
2302
2303 let tool = tool_for_wiremock();
2304 let context = ToolContext::new(SessionId::new());
2306
2307 let result = tool
2308 .execute_with_context(
2309 serde_json::json!({
2310 "url": format!("{}/file.txt", mock_server.uri()),
2311 "save_to_file": "/downloads/file.txt"
2312 }),
2313 &context,
2314 )
2315 .await;
2316
2317 if let ToolExecutionResult::ToolError(msg) = result {
2318 assert!(
2319 msg.contains("not available"),
2320 "Expected file system not available error, got: {}",
2321 msg
2322 );
2323 } else {
2324 panic!("Expected tool error, got: {:?}", result);
2325 }
2326 }
2327
2328 #[tokio::test]
2329 async fn test_save_to_file_disabled_by_config_returns_error() {
2330 let mock_server = MockServer::start().await;
2331
2332 Mock::given(method("GET"))
2333 .and(path("/file.txt"))
2334 .respond_with(ResponseTemplate::new(200).set_body_string("content"))
2335 .mount(&mock_server)
2336 .await;
2337
2338 let tool = WebFetchTool::new(false, None);
2339 let file_store = Arc::new(MockFileStore::new());
2340 let session_id = SessionId::new();
2341 let context = ToolContext::with_file_store(session_id, file_store.clone());
2342
2343 let result = tool
2344 .execute_with_context(
2345 serde_json::json!({
2346 "url": format!("{}/file.txt", mock_server.uri()),
2347 "save_to_file": "/downloads/file.txt"
2348 }),
2349 &context,
2350 )
2351 .await;
2352
2353 if let ToolExecutionResult::ToolError(msg) = result {
2354 assert!(
2355 msg.contains("disabled"),
2356 "Expected file download disabled error, got: {}",
2357 msg
2358 );
2359 } else {
2360 panic!("Expected tool error, got: {:?}", result);
2361 }
2362
2363 assert!(
2364 file_store
2365 .get_file(session_id, "/downloads/file.txt")
2366 .await
2367 .is_none(),
2368 "File should not be written when save_to_file is disabled",
2369 );
2370 }
2371
2372 #[tokio::test]
2373 async fn test_save_to_file_without_context_strips_save() {
2374 let mock_server = MockServer::start().await;
2376
2377 Mock::given(method("GET"))
2378 .and(path("/file.txt"))
2379 .respond_with(
2380 ResponseTemplate::new(200)
2381 .set_body_string("hello")
2382 .insert_header("content-type", "text/plain"),
2383 )
2384 .mount(&mock_server)
2385 .await;
2386
2387 let tool = tool_for_wiremock();
2388 let result = tool
2389 .execute(serde_json::json!({
2390 "url": format!("{}/file.txt", mock_server.uri()),
2391 "save_to_file": "/downloads/file.txt"
2392 }))
2393 .await;
2394
2395 if let ToolExecutionResult::Success(value) = result {
2397 assert_eq!(value["status_code"], 200);
2398 assert!(value["content"].as_str().is_some());
2399 assert!(value.get("saved_path").is_none() || value["saved_path"].is_null());
2400 } else {
2401 panic!("Expected successful response, got: {:?}", result);
2402 }
2403 }
2404
2405 struct CannedEgress;
2414
2415 #[async_trait]
2416 impl crate::egress::EgressService for CannedEgress {
2417 async fn send(
2418 &self,
2419 _request: crate::egress::EgressRequest,
2420 ) -> crate::egress::EgressResult<crate::egress::EgressResponse> {
2421 Ok(crate::egress::EgressResponse {
2422 status: 200,
2423 headers: [("content-type".to_string(), "text/plain".to_string())]
2424 .into_iter()
2425 .collect(),
2426 body: b"pong from egress".to_vec(),
2427 })
2428 }
2429
2430 async fn send_stream(
2431 &self,
2432 request: crate::egress::EgressRequest,
2433 ) -> crate::egress::EgressResult<crate::egress::EgressStreamResponse> {
2434 let response = self.send(request).await?;
2435 Ok(crate::egress::EgressStreamResponse {
2436 status: response.status,
2437 headers: response.headers,
2438 body: Box::pin(futures::stream::once(async move { Ok(response.body) })),
2439 })
2440 }
2441 }
2442
2443 struct RedirectingEgress {
2444 requests: std::sync::Mutex<Vec<String>>,
2445 }
2446
2447 impl RedirectingEgress {
2448 fn requested_urls(&self) -> Vec<String> {
2449 self.requests.lock().unwrap().clone()
2450 }
2451 }
2452
2453 #[async_trait]
2454 impl crate::egress::EgressService for RedirectingEgress {
2455 async fn send(
2456 &self,
2457 request: crate::egress::EgressRequest,
2458 ) -> crate::egress::EgressResult<crate::egress::EgressResponse> {
2459 let url = request.url.clone();
2460 self.requests.lock().unwrap().push(request.url);
2461 if url == "http://93.184.216.35/final" {
2466 return Ok(crate::egress::EgressResponse {
2467 status: 200,
2468 headers: [("content-type".to_string(), "text/plain".to_string())]
2469 .into_iter()
2470 .collect(),
2471 body: b"final".to_vec(),
2472 });
2473 }
2474 Ok(crate::egress::EgressResponse {
2475 status: 302,
2476 headers: [(
2477 "location".to_string(),
2478 "http://93.184.216.35/final".to_string(),
2479 )]
2480 .into_iter()
2481 .collect(),
2482 body: Vec::new(),
2483 })
2484 }
2485
2486 async fn send_stream(
2487 &self,
2488 request: crate::egress::EgressRequest,
2489 ) -> crate::egress::EgressResult<crate::egress::EgressStreamResponse> {
2490 let response = self.send(request).await?;
2491 Ok(crate::egress::EgressStreamResponse {
2492 status: response.status,
2493 headers: response.headers,
2494 body: Box::pin(futures::stream::once(async move { Ok(response.body) })),
2495 })
2496 }
2497 }
2498
2499 #[tokio::test]
2500 async fn test_execute_with_context_routes_through_egress() {
2501 let tool = WebFetchTool::default();
2502 let mut context = ToolContext::new(SessionId::new());
2503 context.egress_service = Some(Arc::new(CannedEgress));
2504
2505 let result = tool
2506 .execute_with_context(
2507 serde_json::json!({ "url": "http://93.184.216.34/ping" }),
2508 &context,
2509 )
2510 .await;
2511
2512 if let ToolExecutionResult::Success(value) = result {
2513 assert_eq!(value["status_code"], 200);
2514 assert_eq!(value["content"], "pong from egress");
2515 } else {
2516 panic!("Expected successful egress-path response, got: {result:?}");
2517 }
2518 }
2519
2520 #[tokio::test]
2521 async fn test_egress_path_system_allowlist_blocks_cross_host_redirect_before_second_hop() {
2522 use crate::system_allowlist::SystemAllowlist;
2523
2524 let tool = WebFetchTool {
2525 system_allowlist: Some(
2526 SystemAllowlist::from_toml("[groups.test]\nallowed = [\"93.184.216.34\"]\n")
2527 .map(Arc::new)
2528 .unwrap(),
2529 ),
2530 ..Default::default()
2531 };
2532 let egress = Arc::new(RedirectingEgress {
2533 requests: std::sync::Mutex::new(Vec::new()),
2534 });
2535 let mut context = ToolContext::new(SessionId::new());
2536 context.egress_service = Some(egress.clone());
2537
2538 let result = tool
2539 .execute_with_context(
2540 serde_json::json!({ "url": "http://93.184.216.34/start" }),
2541 &context,
2542 )
2543 .await;
2544
2545 assert!(
2546 matches!(&result, ToolExecutionResult::ToolError(msg) if msg.contains("blocked")),
2547 "expected cross-host redirect denial, got: {result:?}"
2548 );
2549 assert_eq!(
2550 egress.requested_urls(),
2551 vec!["http://93.184.216.34/start"],
2552 "redirect target must be rejected before a second egress hop can resolve it"
2553 );
2554 }
2555
2556 #[tokio::test]
2557 async fn test_egress_path_denies_url_outside_network_access_list() {
2558 let tool = WebFetchTool::default();
2559 let mut context = ToolContext::new(SessionId::new());
2560 context.egress_service = Some(Arc::new(CannedEgress));
2561 context.network_access = Some(crate::network_access::NetworkAccessList::allow_only([
2562 "allowed.example.com",
2563 ]));
2564
2565 let result = tool
2566 .execute_with_context(
2567 serde_json::json!({ "url": "http://93.184.216.34/ping" }),
2568 &context,
2569 )
2570 .await;
2571
2572 assert!(
2573 matches!(
2574 &result,
2575 ToolExecutionResult::ToolError(msg) if msg.contains("blocked by network access policy")
2576 ),
2577 "expected network access denial, got: {result:?}"
2578 );
2579 }
2580
2581 #[tokio::test]
2582 async fn test_egress_path_blocks_private_address_before_sending() {
2583 let tool = WebFetchTool::default();
2584 let mut context = ToolContext::new(SessionId::new());
2585 context.egress_service = Some(Arc::new(CannedEgress));
2586
2587 let result = tool
2588 .execute_with_context(
2589 serde_json::json!({ "url": "http://169.254.169.254/latest/meta-data/" }),
2590 &context,
2591 )
2592 .await;
2593
2594 assert!(
2595 matches!(
2596 &result,
2597 ToolExecutionResult::ToolError(msg) if msg.contains("blocked")
2598 ),
2599 "expected SSRF block on egress path, got: {result:?}"
2600 );
2601 }
2602
2603 #[tokio::test]
2604 async fn test_egress_path_save_to_file_writes_session_file() {
2605 let tool = WebFetchTool::new(true, None);
2606 let file_store = Arc::new(MockFileStore::new());
2607 let session_id = SessionId::new();
2608 let mut context = ToolContext::with_file_store(session_id, file_store.clone());
2609 context.egress_service = Some(Arc::new(CannedEgress));
2610
2611 let result = tool
2612 .execute_with_context(
2613 serde_json::json!({
2614 "url": "http://93.184.216.34/file.txt",
2615 "save_to_file": "/downloads/file.txt"
2616 }),
2617 &context,
2618 )
2619 .await;
2620
2621 if let ToolExecutionResult::Success(value) = result {
2622 assert_eq!(value["saved_path"], "/downloads/file.txt");
2623 let (content, encoding) = file_store
2624 .get_file(session_id, "/downloads/file.txt")
2625 .await
2626 .expect("file should have been written via egress path");
2627 assert_eq!(encoding, "text");
2628 assert_eq!(content, "pong from egress");
2629 } else {
2630 panic!("Expected successful response, got: {result:?}");
2631 }
2632 }
2633}