Skip to main content

everruns_core/capabilities/web_fetch/
mod.rs

1//! WebFetch Capability — fetches web content via fetchkit
2//!
3//! Design decisions:
4//! - All metadata (description, schema, llmtxt) comes from fetchkit::ToolBuilder
5//! - File download (`save_to_file`) auto-enabled when `session_file_system` is a sibling
6//! - Bot-auth (Ed25519 request signing per RFC 9421) enabled via server-wide env vars
7//! - Binary content accepted for file downloads, rejected for inline responses
8//! - See specs/fetchkit.md for design details
9//!
10//! Trust boundary (TM-AGENT-013, TM-AGENT-018, TM-API-008):
11//! - `risk_level()` returns `High`. Per the capability admin-only tier contract
12//!   (`specs/capabilities.md`, `specs/permissions.md`), assigning `web_fetch`
13//!   to an agent requires `OrgRole::Admin`; the canonical create/update gate is
14//!   `check_high_risk_caps` in `crates/server/src/domains/agents/commands.rs`
15//!   (invoked from `CreateAgent::execute`, `UpdateAgent::execute`, and
16//!   `UpsertAgent::execute`). The sibling `require_admin_for_high_risk` helper
17//!   in `crates/server/src/api/agents.rs` enforces the same contract on
18//!   agent-import / copy paths. Existing member-owned agents that already had
19//!   `web_fetch` before the elevation continue to run (gate is creation/update
20//!   only, not runtime). New assignments by non-admin members are rejected
21//!   with HTTP 403.
22//! - Rationale: outbound HTTP from an agent doubles as both a data-exfiltration
23//!   channel (TM-AGENT-013) and an SSRF vector if egress is not strictly
24//!   isolated. TM-API-008 is mitigated by SSRF validation (DNS-pinned on the
25//!   egress path, `DnsPolicy::block_private_ips()` on the legacy path);
26//!   TM-AGENT-018 is mitigated by the per-layer `NetworkAccessList` plus the
27//!   deployment-wide system allowlist enforced at the egress boundary.
28//!   Admin assignment remains the explicit trust gate for enabling the
29//!   capability at all.
30
31use 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/// Ed25519 public key JWK derived from a signing key seed.
48///
49/// Used to register the public key in the HTTP message signatures directory
50/// so target servers can verify request signatures.
51#[derive(Debug, Clone)]
52pub struct BotAuthPublicKey {
53    /// JWK Thumbprint (RFC 7638) — matches `BotAuthConfig::keyid()`
54    pub key_id: String,
55    /// Full JWK object: `{"kty":"OKP","crv":"Ed25519","x":"<base64url>"}`
56    pub jwk: serde_json::Value,
57}
58
59/// Derive the Ed25519 public key JWK and key ID from a base64url-encoded seed.
60///
61/// Returns `None` if the seed is invalid. The key_id is the JWK Thumbprint
62/// (base64url-encoded SHA-256 of the canonical JWK representation), matching
63/// the keyid that fetchkit's `BotAuthConfig` puts in `Signature-Input`.
64pub 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    // Decode seed (base64url, no padding)
70    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    // Derive public key
80    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    // Build canonical JWK (RFC 7638 member ordering for OKP: crv, kty, x)
86    let canonical_jwk = format!(
87        r#"{{"crv":"Ed25519","kty":"OKP","x":"{}"}}"#,
88        public_key_b64
89    );
90
91    // JWK Thumbprint = base64url(SHA-256(canonical_jwk))
92    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
104/// WebFetch capability — fetches web content, optionally saves to session filesystem.
105///
106/// File download is enabled via per-capability config: `{"enable_file_download": true}`.
107/// Bot-auth signing is server-wide: set `BOT_AUTH_SIGNING_KEY_SEED` env var.
108/// Description, schema, and system prompt all come from fetchkit's ToolBuilder,
109/// adapting to whether file download is on.
110pub struct WebFetchCapability {
111    /// Server-wide bot-auth config (from env vars). When set, all outbound
112    /// HTTP requests are signed with Ed25519 per RFC 9421.
113    bot_auth: Option<BotAuthConfig>,
114}
115
116impl WebFetchCapability {
117    /// Create with optional server-wide bot-auth signing config.
118    pub fn new(bot_auth: Option<BotAuthConfig>) -> Self {
119        Self { bot_auth }
120    }
121
122    /// Create from environment variables.
123    ///
124    /// - `BOT_AUTH_SIGNING_KEY_SEED`: base64url-encoded 32-byte Ed25519 seed (required to enable)
125    /// - `BOT_AUTH_AGENT_FQDN`: FQDN for Signature-Agent header (optional)
126    /// - `BOT_AUTH_VALIDITY_SECS`: signature validity in seconds (optional, default 300)
127    pub fn from_env() -> Self {
128        Self {
129            bot_auth: bot_auth_config_from_env(),
130        }
131    }
132}
133
134/// Read bot-auth config from environment variables.
135fn 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        // Preview with all features for UI display
196        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        // Behavioral note only — parameter details live in the tool's JSON
211        // schema. The full fetchkit llmtxt remains available via
212        // `system_prompt_preview()` for UI display but is not injected on
213        // every turn. The `save_to_file` mention is gated on the same
214        // `enable_file_download` flag the tool itself uses, so the prompt
215        // matches the actually-available capability.
216        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
313// ============================================================================
314// SessionFileSaver — bridges fetchkit::FileSaver to SessionFileSystem
315// ============================================================================
316
317/// Adapter that routes fetchkit file saves through the session virtual filesystem.
318///
319/// Binary content is encoded as base64; text content is stored as-is.
320struct 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        // SessionFileSystem is the path authority: this preserves mount routing,
335        // agent-facing display identity, and backend containment policy.
336        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        // Revisit after upgrading fetchkit beyond 0.4.1: confirm upstream calls
367        // validate_path before network I/O. Keep this save-time check as defense
368        // in depth unless the FileSaver contract guarantees the path is unchanged.
369        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
395// ============================================================================
396// Tool: web_fetch
397// ============================================================================
398
399/// Tool that fetches content from a URL using fetchkit
400///
401/// THREAT[TM-API-008]: SSRF protection via fetchkit DnsPolicy
402/// Mitigation: Default FetchOptions uses DnsPolicy::block_private_ips(),
403/// which blocks loopback, RFC1918, link-local (cloud metadata), and other
404/// reserved IP ranges via resolve-then-check with DNS pinning.
405///
406/// File download: when `save_to_file` is provided, content is saved through
407/// the session filesystem (SessionFileSystem) via the SessionFileSaver adapter.
408pub struct WebFetchTool {
409    /// Builder template for this tool's fetchkit configuration. Cloned per
410    /// execution to inject the egress transport when the context provides an
411    /// `EgressService` (see `egress_transport`).
412    builder: fetchkit::ToolBuilder,
413    /// Direct (non-egress) tool built from `builder`: serves metadata
414    /// (schema/description) and execution for contexts without an egress
415    /// service (e.g. embedded hosts), where fetchkit owns the HTTP client.
416    fetchkit_tool: fetchkit::Tool,
417    enable_save_to_file: bool,
418    /// Cached description from ToolBuilder (owned copy of fetchkit's &str for our Tool trait)
419    description: String,
420    /// Host-wide system allowlist ("green list"), pre-checked on the initial
421    /// URL for a clear, distinct system-policy error. On the egress path the
422    /// boundary independently re-enforces it (final enforcement point, every
423    /// hop); on the direct path this pre-flight is the only enforcement.
424    /// `None` = no global enforcement. See `crate::system_allowlist` and
425    /// `specs/system-allowlist.md`.
426    system_allowlist: Option<Arc<crate::system_allowlist::SystemAllowlist>>,
427}
428
429impl WebFetchTool {
430    /// Create a new WebFetchTool with file download and optional bot-auth signing.
431    pub fn new(enable_save_to_file: bool, bot_auth: Option<BotAuthConfig>) -> Self {
432        // THREAT[TM-TOOL-024]: Rendering stays request-opt-in; FetchKit runs
433        // inline scripts with a timeout, denies renderer subresource traffic,
434        // and caps rendered output before conversion.
435        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    /// Reject URLs not covered by the active system allowlist with an explicit
453    /// system-policy error. Returns `None` when the allowlist is disabled or the
454    /// URL is permitted.
455    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    /// Build a FetchRequest from JSON arguments.
476    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        // Deserialize the upstream request contract so newly adopted FetchKit
506        // fields are forwarded instead of silently discarded by this adapter.
507        // Preserve the wrapper's case-insensitive method handling and trim file
508        // destinations for callers that bypass JSON Schema validation.
509        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    /// Map a fetchkit error to a ToolExecutionResult.
533    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        // Needed for save_to_file (SessionFileSystem access)
590        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        // Without context, save_to_file is not supported — execute normally
602        let request = match Self::parse_request(&arguments) {
603            Ok(mut req) => {
604                req.save_to_file = None; // Cannot save without context
605                req
606            }
607            Err(e) => return e,
608        };
609
610        // Host-wide system allowlist applies even without a session context.
611        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        // Host-wide system allowlist, pre-checked on the initial URL for a
642        // clear, distinct system-policy error. On the egress path the boundary
643        // independently re-enforces it on every hop.
644        if let Some(blocked) = self.system_policy_block(&request.url) {
645            return blocked;
646        }
647
648        // THREAT[TM-AGENT-018]: Enforce network access list. This is the
649        // user-facing pre-check; on the egress path the boundary re-checks it
650        // on every hop.
651        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        // Egress-backed path (specs/egress.md migration step 3): when the host
661        // provides an egress service, inject it as fetchkit's HTTP transport.
662        // fetchkit keeps the whole pipeline (specialized fetchers, DNS policy,
663        // per-hop redirect validation, bot-auth signing, body caps); every
664        // HTTP hop crosses the egress boundary, which enforces the network
665        // access list and the system allowlist. Without an egress service
666        // (e.g. embedded hosts) fetchkit owns the HTTP client directly.
667        let routed_tool;
668        let tool = match &context.egress_service {
669            Some(egress) => {
670                // The system allowlist is enforced again at the egress boundary,
671                // but fetchkit resolves redirect targets before invoking the transport.
672                // When the allowlist is active, keep redirects on the already
673                // preflighted host so disallowed cross-host redirect labels cannot
674                // leak via DNS before the boundary denies the request.
675                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 no save_to_file, use the simple path (no saver needed)
691        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        // save_to_file requested — need SessionFileSystem
703        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    /// Create a WebFetchTool with permissive DNS policy for wiremock tests
736    /// (wiremock binds to 127.0.0.1 which is blocked by default).
737    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        // No egress service → legacy path; ACL denies the URL too.
793        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        // 32 bytes of 'A' (0x41), base64url-encoded
817        let seed = "QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUE";
818        let pk = super::derive_bot_auth_public_key(seed).unwrap();
819
820        // JWK has correct structure
821        assert_eq!(pk.jwk["kty"], "OKP");
822        assert_eq!(pk.jwk["crv"], "Ed25519");
823        assert!(pk.jwk["x"].is_string());
824
825        // key_id matches fetchkit's BotAuthConfig::keyid()
826        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        // System prompt comes from fetchkit ToolBuilder via system_prompt_contribution_with_config
864        assert!(cap.system_prompt_addition().is_none());
865        // Preview shows full features for UI
866        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    // Integration tests using wiremock
920    #[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            // HEAD requests should not have content
981            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            // Size should be present and > 0
1012            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        // Simulate a PNG image response
1023        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]) // PNG magic bytes
1028                    .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        // Binary content should return success with error message and metadata
1042        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            // Should have size metadata if available
1055            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        // Normal response should have truncated: false
1076        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            // truncated should be false or null for non-truncated content
1085            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        // Use TEST-NET-1 (192.0.2.0/24, RFC 5737) which is non-routable and will timeout.
1098        // Note: fetchkit v0.1.2 blocks RFC1918 private IPs, but TEST-NET ranges
1099        // are also blocked by DNS policy. Use a wiremock server with a delay instead.
1100        let mock_server = MockServer::start().await;
1101
1102        // Mount a mock that takes 5 seconds to respond (exceeds 1s first-byte timeout)
1103        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                // Some environments may handle timeouts differently
1130            }
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            // Verify all expected fields are present
1157            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            // format, content may or may not be present depending on response type
1168        } 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            // HEAD response should have metadata but not content
1197            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            // Should NOT have content for HEAD
1202            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        // No as_markdown needed - fetchkit returns markdown by default for HTML
1226        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            // Content should be present
1235            let content = value["content"].as_str().unwrap();
1236            assert!(content.contains("Title") || content.contains("Content"));
1237            // Format should be "markdown" or "raw" depending on fetchkit's detection
1238            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            // Content should be present
1305            let content = value["content"].as_str().unwrap();
1306            assert!(content.contains("Test") || content.contains("content"));
1307            // Format should be "text" or "raw" depending on fetchkit's detection
1308            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            // JSON content should return "raw" format
1338            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        // 404 should still be a "success" from tool perspective - it got a response
1362        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        // 500 should still be a "success" from tool perspective
1387        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        // DNS failure returns a tool error. With fetchkit v0.1.2's resolve-then-check,
1404        // DNS resolution failures may surface as "blocked by policy" since the hostname
1405        // cannot be validated against the DNS policy.
1406        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            // Some environments might timeout instead of DNS failure
1419        }
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        // Note: mock_server.uri() returns http:// URL
1470        let result = tool
1471            .execute(serde_json::json!({
1472                "url": format!("{}/get", mock_server.uri())
1473            }))
1474            .await;
1475
1476        // HTTP (not HTTPS) should work
1477        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        // Response with many consecutive newlines
1489        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            // Should have at most 2 consecutive newlines
1509            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    // ========================================================================
1519    // SSRF security tests (TM-API-008 through TM-API-012)
1520    //
1521    // fetchkit v0.1.2 blocks private/internal IPs by default via
1522    // resolve-then-check with DNS pinning. These tests verify that
1523    // private/internal URLs are blocked by policy.
1524    //
1525    // Run with: cargo test -p everruns-core --lib -- web_fetch::tests::test_ssrf
1526    // ========================================================================
1527
1528    // Helper: asserts that a private/internal URL IS blocked by fetchkit's
1529    // DNS policy (SSRF protection). The tool should return a ToolError
1530    // containing "blocked".
1531    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    /// THREAT[TM-API-009]: Cloud metadata endpoint blocked by fetchkit DNS policy.
1542    #[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    /// THREAT[TM-API-008]: Localhost blocked by fetchkit DNS policy.
1548    #[tokio::test]
1549    async fn test_ssrf_localhost_blocked() {
1550        assert_blocked_by_policy("http://127.0.0.1:1/").await;
1551    }
1552
1553    /// THREAT[TM-API-008]: RFC1918 10.x.x.x blocked by fetchkit DNS policy.
1554    #[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    /// THREAT[TM-API-008]: RFC1918 172.16.x.x blocked by fetchkit DNS policy.
1560    #[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    /// THREAT[TM-API-008]: RFC1918 192.168.x.x blocked by fetchkit DNS policy.
1566    #[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    /// THREAT[TM-API-008]: IPv6 localhost blocked by fetchkit DNS policy.
1572    #[tokio::test]
1573    async fn test_ssrf_ipv6_localhost_blocked() {
1574        assert_blocked_by_policy("http://[::1]:1/").await;
1575    }
1576
1577    /// THREAT[TM-API-008]: 0.0.0.0 blocked by fetchkit DNS policy.
1578    #[tokio::test]
1579    async fn test_ssrf_unspecified_blocked() {
1580        assert_blocked_by_policy("http://0.0.0.0:1/").await;
1581    }
1582
1583    /// Verify file://, ftp://, gopher:// schemes are blocked (existing protection).
1584    #[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    // ========================================================================
1602    // Integration tests using wiremock (no network access needed)
1603    // ========================================================================
1604
1605    #[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        // Build a page with >500 chars of content
1731        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    // ========================================================================
1847    // File download tests (save_to_file via SessionFileSaver)
1848    // ========================================================================
1849
1850    /// In-memory SessionFileSystem for testing file downloads
1851    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        // Default (no file download): save_to_file NOT in schema
1999        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        // With file download enabled: save_to_file in schema
2007        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        // Without config: no save_to_file in schema
2103        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        // With enable_file_download: save_to_file in schema
2109        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        // Without file download: no save_to_file mention in prompt
2121        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        // With file download: save_to_file documented in prompt
2128        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            // Content should NOT be inline when saving to file
2172            assert!(
2173                value.get("content").is_none() || value["content"].is_null(),
2174                "Content should not be inline when saving to file"
2175            );
2176
2177            // Verify file was written to the store
2178            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        // Serve a PNG image (binary content)
2242        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            // Verify file was written as base64 (binary content)
2277            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            // Decode and verify
2284            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        // Context without file_store
2305        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        // When execute() is called (no context), save_to_file should be ignored
2375        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        // Should succeed with inline content (save_to_file stripped)
2396        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    // ========================================================================
2406    // Egress-backed path (specs/egress.md migration step 3)
2407    //
2408    // URLs use public IP literals so `validate_url_dns_pinned` passes without
2409    // DNS; the egress mock never performs real network I/O.
2410    // ========================================================================
2411
2412    /// Canned egress service: always returns 200 text/plain "pong from egress".
2413    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            // The final host returns 200; only the initial host issues the
2462            // cross-host redirect. Returning 200 on the final URL keeps the test
2463            // deterministic — if the same-host-only policy ever regressed, the
2464            // second hop would terminate here instead of self-redirecting.
2465            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}