返回 DeepSeek-TUI-2026
web_run.rs
根目录 / crates / tui / src / tools / web_run.rs
1 //! Web browsing tool with multi-command support (search/open/click/find/screenshot).
2 //!
3 //! This mirrors the Codex harness `web.run` interface so models can use a single
4 //! tool call to perform multiple web actions and cite sources with ref_ids.
5
6 use super::spec::{
7 ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
8 optional_u64, required_str,
9 };
10 use crate::network_policy::{Decision, host_from_url};
11 use async_trait::async_trait;
12 use base64::{Engine as _, engine::general_purpose};
13 use regex::Regex;
14 use serde::{Deserialize, Serialize};
15 use serde_json::{Value, json};
16 use std::collections::{HashMap, VecDeque};
17 use std::hash::{Hash, Hasher};
18 use std::sync::{Mutex, OnceLock};
19 use std::time::{Duration, Instant};
20
21 const MAX_RESULTS: usize = 10;
22 const DEFAULT_TIMEOUT_MS: u64 = 15_000;
23 const DEFAULT_OPEN_TIMEOUT_MS: u64 = 20_000;
24 const MAX_WEB_RUN_SESSIONS: usize = 64;
25 const MAX_PAGES_PER_SESSION: usize = 256;
26 const WEB_RUN_SESSION_TTL: Duration = Duration::from_secs(30 * 60);
27 const USER_AGENT: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15";
28
29 static WEB_RUN_STATE: OnceLock<Mutex<WebRunState>> = OnceLock::new();
30
31 #[derive(Default)]
32 struct WebRunState {
33 sessions: HashMap<String, WebRunSessionState>,
34 pages: HashMap<String, StoredWebPage>,
35 }
36
37 struct WebRunSessionState {
38 next_turn: u64,
39 refs: VecDeque<String>,
40 last_access: Instant,
41 }
42
43 impl Default for WebRunSessionState {
44 fn default() -> Self {
45 Self {
46 next_turn: 0,
47 refs: VecDeque::new(),
48 last_access: Instant::now(),
49 }
50 }
51 }
52
53 #[derive(Debug, Clone)]
54 struct StoredWebPage {
55 namespace: String,
56 page: WebPage,
57 }
58
59 impl WebRunState {
60 fn cleanup(&mut self) {
61 let now = Instant::now();
62 let expired = self
63 .sessions
64 .iter()
65 .filter_map(|(namespace, session)| {
66 if now.duration_since(session.last_access) > WEB_RUN_SESSION_TTL {
67 Some(namespace.clone())
68 } else {
69 None
70 }
71 })
72 .collect::<Vec<_>>();
73 for namespace in expired {
74 self.remove_session(&namespace);
75 }
76
77 while self.sessions.len() > MAX_WEB_RUN_SESSIONS {
78 let Some(oldest_namespace) = self
79 .sessions
80 .iter()
81 .min_by_key(|(_, session)| session.last_access)
82 .map(|(namespace, _)| namespace.clone())
83 else {
84 break;
85 };
86 self.remove_session(&oldest_namespace);
87 }
88 }
89
90 fn remove_session(&mut self, namespace: &str) {
91 if let Some(session) = self.sessions.remove(namespace) {
92 for ref_id in session.refs {
93 self.pages.remove(&ref_id);
94 }
95 }
96 }
97
98 fn touch_session(&mut self, namespace: &str) {
99 self.cleanup();
100 if !self.sessions.contains_key(namespace)
101 && self.sessions.len() >= MAX_WEB_RUN_SESSIONS
102 && let Some(oldest_namespace) = self
103 .sessions
104 .iter()
105 .min_by_key(|(_, session)| session.last_access)
106 .map(|(existing_namespace, _)| existing_namespace.clone())
107 {
108 self.remove_session(&oldest_namespace);
109 }
110
111 let session = self.sessions.entry(namespace.to_string()).or_default();
112 session.last_access = Instant::now();
113 }
114
115 fn next_turn(&mut self, namespace: &str) -> u64 {
116 self.touch_session(namespace);
117 let session = self
118 .sessions
119 .get_mut(namespace)
120 .expect("session should exist after touch");
121 let current = session.next_turn;
122 session.next_turn = session.next_turn.saturating_add(1);
123 current
124 }
125
126 fn store_page(&mut self, namespace: &str, ref_id: &str, page: WebPage) {
127 self.touch_session(namespace);
128 let mut evicted_refs = Vec::new();
129 {
130 let session = self
131 .sessions
132 .get_mut(namespace)
133 .expect("session should exist after touch");
134 if let Some(existing_idx) = session.refs.iter().position(|existing| existing == ref_id)
135 {
136 session.refs.remove(existing_idx);
137 }
138 session.refs.push_back(ref_id.to_string());
139
140 while session.refs.len() > MAX_PAGES_PER_SESSION {
141 if let Some(evicted_ref) = session.refs.pop_front() {
142 evicted_refs.push(evicted_ref);
143 }
144 }
145 }
146
147 self.pages.insert(
148 ref_id.to_string(),
149 StoredWebPage {
150 namespace: namespace.to_string(),
151 page,
152 },
153 );
154 for evicted_ref in evicted_refs {
155 self.pages.remove(&evicted_ref);
156 }
157 }
158
159 fn get_page(&mut self, ref_id: &str) -> Option<WebPage> {
160 self.cleanup();
161 let stored = self.pages.get(ref_id)?.clone();
162 if let Some(session) = self.sessions.get_mut(&stored.namespace) {
163 session.last_access = Instant::now();
164 }
165 Some(stored.page)
166 }
167 }
168
169 #[derive(Debug, Clone, Serialize)]
170 struct WebLink {
171 id: usize,
172 url: String,
173 text: String,
174 }
175
176 #[derive(Debug, Clone)]
177 struct WebPage {
178 url: String,
179 title: Option<String>,
180 content_type: Option<String>,
181 lines: Vec<String>,
182 links: Vec<WebLink>,
183 pdf_pages: Option<Vec<Vec<String>>>,
184 }
185
186 #[derive(Debug, Clone, Copy)]
187 enum ResponseLength {
188 Short,
189 Medium,
190 Long,
191 }
192
193 impl ResponseLength {
194 fn from_input(input: Option<&Value>) -> Self {
195 let raw = input.and_then(|v| v.as_str()).unwrap_or("medium");
196 match raw.to_lowercase().as_str() {
197 "short" => Self::Short,
198 "long" => Self::Long,
199 _ => Self::Medium,
200 }
201 }
202
203 fn view_lines(self) -> usize {
204 match self {
205 Self::Short => 40,
206 Self::Medium => 80,
207 Self::Long => 160,
208 }
209 }
210
211 fn wrap_width(self) -> usize {
212 match self {
213 Self::Short => 88,
214 Self::Medium => 110,
215 Self::Long => 140,
216 }
217 }
218
219 fn max_results(self) -> usize {
220 match self {
221 Self::Short => 5,
222 Self::Medium => 8,
223 Self::Long => 10,
224 }
225 }
226
227 fn max_find_matches(self) -> usize {
228 match self {
229 Self::Short => 8,
230 Self::Medium => 15,
231 Self::Long => 30,
232 }
233 }
234 }
235
236 #[derive(Debug, Clone, Serialize)]
237 struct SearchEntry {
238 title: String,
239 url: String,
240 snippet: Option<String>,
241 }
242
243 #[derive(Debug, Clone, Serialize)]
244 struct SearchResult {
245 ref_id: String,
246 query: String,
247 source: String,
248 count: usize,
249 results: Vec<SearchEntry>,
250 #[serde(skip_serializing_if = "Option::is_none")]
251 warning: Option<String>,
252 }
253
254 #[derive(Debug, Clone, Serialize)]
255 struct PageViewResult {
256 ref_id: String,
257 url: String,
258 #[serde(skip_serializing_if = "Option::is_none")]
259 title: Option<String>,
260 #[serde(skip_serializing_if = "Option::is_none")]
261 content_type: Option<String>,
262 line_start: usize,
263 line_end: usize,
264 total_lines: usize,
265 content: String,
266 links: Vec<WebLink>,
267 }
268
269 #[derive(Debug, Clone, Serialize)]
270 struct FindMatch {
271 line: usize,
272 text: String,
273 }
274
275 #[derive(Debug, Clone, Serialize)]
276 struct FindResult {
277 ref_id: String,
278 pattern: String,
279 count: usize,
280 matches: Vec<FindMatch>,
281 }
282
283 #[derive(Debug, Clone, Serialize)]
284 struct ScreenshotResult {
285 ref_id: String,
286 pageno: usize,
287 total_pages: usize,
288 content: String,
289 }
290
291 #[derive(Debug, Clone, Serialize)]
292 struct ImageResultEntry {
293 image: String,
294 #[serde(skip_serializing_if = "Option::is_none")]
295 thumbnail: Option<String>,
296 #[serde(skip_serializing_if = "Option::is_none")]
297 title: Option<String>,
298 #[serde(skip_serializing_if = "Option::is_none")]
299 url: Option<String>,
300 #[serde(skip_serializing_if = "Option::is_none")]
301 source: Option<String>,
302 #[serde(skip_serializing_if = "Option::is_none")]
303 width: Option<u32>,
304 #[serde(skip_serializing_if = "Option::is_none")]
305 height: Option<u32>,
306 }
307
308 #[derive(Debug, Clone, Serialize)]
309 struct ImageQueryResult {
310 query: String,
311 source: String,
312 count: usize,
313 results: Vec<ImageResultEntry>,
314 #[serde(skip_serializing_if = "Option::is_none")]
315 warning: Option<String>,
316 }
317
318 #[derive(Debug, Clone, Serialize, Default)]
319 struct WebRunOutput {
320 #[serde(skip_serializing_if = "Option::is_none")]
321 search_query: Option<Vec<SearchResult>>,
322 #[serde(skip_serializing_if = "Option::is_none")]
323 image_query: Option<Vec<ImageQueryResult>>,
324 #[serde(skip_serializing_if = "Option::is_none")]
325 open: Option<Vec<PageViewResult>>,
326 #[serde(skip_serializing_if = "Option::is_none")]
327 click: Option<Vec<PageViewResult>>,
328 #[serde(skip_serializing_if = "Option::is_none")]
329 find: Option<Vec<FindResult>>,
330 #[serde(skip_serializing_if = "Option::is_none")]
331 screenshot: Option<Vec<ScreenshotResult>>,
332 #[serde(skip_serializing_if = "Vec::is_empty", default)]
333 warnings: Vec<String>,
334 }
335
336 pub struct WebRunTool;
337
338 #[async_trait]
339 impl ToolSpec for WebRunTool {
340 fn name(&self) -> &'static str {
341 "web.run"
342 }
343
344 fn description(&self) -> &'static str {
345 "Browse the web (search/open/click/find/screenshot/image_query) and return structured results with ref_ids for citations."
346 }
347
348 fn input_schema(&self) -> Value {
349 json!({
350 "type": "object",
351 "properties": {
352 "search_query": {
353 "type": "array",
354 "items": {
355 "type": "object",
356 "properties": {
357 "q": { "type": "string" },
358 "recency": { "type": "integer" },
359 "max_results": { "type": "integer" },
360 "timeout_ms": { "type": "integer" },
361 "domains": { "type": "array", "items": { "type": "string" } }
362 },
363 "required": ["q"]
364 }
365 },
366 "image_query": {
367 "type": "array",
368 "items": {
369 "type": "object",
370 "properties": {
371 "q": { "type": "string" },
372 "recency": { "type": "integer" },
373 "max_results": { "type": "integer" },
374 "timeout_ms": { "type": "integer" },
375 "domains": { "type": "array", "items": { "type": "string" } }
376 },
377 "required": ["q"]
378 }
379 },
380 "open": {
381 "type": "array",
382 "items": {
383 "type": "object",
384 "properties": {
385 "ref_id": { "type": "string" },
386 "lineno": { "type": "integer" }
387 },
388 "required": ["ref_id"]
389 }
390 },
391 "click": {
392 "type": "array",
393 "items": {
394 "type": "object",
395 "properties": {
396 "ref_id": { "type": "string" },
397 "id": { "type": "integer" }
398 },
399 "required": ["ref_id", "id"]
400 }
401 },
402 "find": {
403 "type": "array",
404 "items": {
405 "type": "object",
406 "properties": {
407 "ref_id": { "type": "string" },
408 "pattern": { "type": "string" }
409 },
410 "required": ["ref_id", "pattern"]
411 }
412 },
413 "screenshot": {
414 "type": "array",
415 "items": {
416 "type": "object",
417 "properties": {
418 "ref_id": { "type": "string" },
419 "pageno": { "type": "integer" }
420 },
421 "required": ["ref_id", "pageno"]
422 }
423 },
424 "response_length": {
425 "type": "string",
426 "enum": ["short", "medium", "long"],
427 "description": "Controls result verbosity"
428 }
429 }
430 })
431 }
432
433 fn capabilities(&self) -> Vec<ToolCapability> {
434 vec![ToolCapability::ReadOnly, ToolCapability::Network]
435 }
436
437 fn approval_requirement(&self) -> ApprovalRequirement {
438 ApprovalRequirement::Auto
439 }
440
441 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
442 let response_length = ResponseLength::from_input(input.get("response_length"));
443 let mut output = WebRunOutput::default();
444 let scope = scoped_ref_prefix(&context.state_namespace);
445 let turn = with_state(|state| state.next_turn(&context.state_namespace));
446
447 let mut search_counter = 0usize;
448 let mut view_counter = 0usize;
449 let mut click_counter = 0usize;
450
451 if let Some(searches) = input.get("search_query").and_then(|v| v.as_array()) {
452 let mut results = Vec::new();
453 for search in searches {
454 let query = required_str(search, "q")?.trim().to_string();
455 if query.is_empty() {
456 continue;
457 }
458 let recency = optional_u64(search, "recency", 0);
459 let max_results = usize::try_from(optional_u64(
460 search,
461 "max_results",
462 response_length.max_results() as u64,
463 ))
464 .unwrap_or(response_length.max_results())
465 .clamp(1, MAX_RESULTS);
466 let timeout_ms = optional_u64(search, "timeout_ms", DEFAULT_TIMEOUT_MS).min(60_000);
467
468 let domains = search
469 .get("domains")
470 .and_then(|v| v.as_array())
471 .map(|arr| {
472 arr.iter()
473 .filter_map(|v| v.as_str().map(|s| s.to_string()))
474 .collect::<Vec<_>>()
475 })
476 .unwrap_or_default();
477
478 let (entries, source, warning) =
479 run_search(&query, max_results, timeout_ms, &domains).await?;
480 let mut warnings = Vec::new();
481 if recency > 0 {
482 warnings.push(format!(
483 "Recency filter not enforced (requested last {recency} days)"
484 ));
485 }
486 if let Some(w) = warning {
487 warnings.push(w);
488 }
489 search_counter += 1;
490 let ref_id = format!("{scope}turn{turn}search{search_counter}");
491
492 let page = page_from_search(&query, &entries);
493 store_page(&context.state_namespace, &ref_id, page);
494
495 results.push(SearchResult {
496 ref_id,
497 query,
498 source,
499 count: entries.len(),
500 results: entries,
501 warning: if warnings.is_empty() {
502 None
503 } else {
504 Some(warnings.join("; "))
505 },
506 });
507 }
508 if !results.is_empty() {
509 output.search_query = Some(results);
510 }
511 }
512
513 if let Some(images) = input.get("image_query").and_then(|v| v.as_array()) {
514 let mut results = Vec::new();
515 for image in images {
516 let query = required_str(image, "q")?.trim().to_string();
517 if query.is_empty() {
518 continue;
519 }
520 let recency = optional_u64(image, "recency", 0);
521 let max_results = usize::try_from(optional_u64(
522 image,
523 "max_results",
524 response_length.max_results() as u64,
525 ))
526 .unwrap_or(response_length.max_results())
527 .clamp(1, MAX_RESULTS);
528 let timeout_ms = optional_u64(image, "timeout_ms", DEFAULT_TIMEOUT_MS).min(60_000);
529
530 let domains = image
531 .get("domains")
532 .and_then(|v| v.as_array())
533 .map(|arr| {
534 arr.iter()
535 .filter_map(|v| v.as_str().map(|s| s.to_string()))
536 .collect::<Vec<_>>()
537 })
538 .unwrap_or_default();
539
540 let (entries, warning) =
541 run_image_search(&query, max_results, timeout_ms, &domains).await?;
542
543 let mut warnings = Vec::new();
544 if recency > 0 {
545 warnings.push(format!(
546 "Recency filter not enforced (requested last {recency} days)"
547 ));
548 }
549 if let Some(w) = warning {
550 warnings.push(w);
551 }
552
553 results.push(ImageQueryResult {
554 query,
555 source: "duckduckgo_images".to_string(),
556 count: entries.len(),
557 results: entries,
558 warning: if warnings.is_empty() {
559 None
560 } else {
561 Some(warnings.join("; "))
562 },
563 });
564 }
565 if !results.is_empty() {
566 output.image_query = Some(results);
567 }
568 }
569
570 if let Some(opens) = input.get("open").and_then(|v| v.as_array()) {
571 let mut views = Vec::new();
572 for open in opens {
573 let ref_id = required_str(open, "ref_id")?.to_string();
574 let lineno = optional_u64(open, "lineno", 1).max(1) as usize;
575
576 let page = resolve_or_fetch_page(&ref_id, DEFAULT_OPEN_TIMEOUT_MS, context).await?;
577 view_counter += 1;
578 let view_ref = format!("{scope}turn{turn}view{view_counter}");
579 store_page(&context.state_namespace, &view_ref, page.clone());
580
581 let view = render_view(&view_ref, &page, lineno, response_length);
582 views.push(view);
583 }
584 if !views.is_empty() {
585 output.open = Some(views);
586 }
587 }
588
589 if let Some(clicks) = input.get("click").and_then(|v| v.as_array()) {
590 let mut views = Vec::new();
591 for click in clicks {
592 let ref_id = required_str(click, "ref_id")?.to_string();
593 let link_id = optional_u64(click, "id", 0) as usize;
594 if link_id == 0 {
595 return Err(ToolError::invalid_input("click.id must be >= 1"));
596 }
597 let page = get_page(&ref_id).ok_or_else(|| {
598 ToolError::invalid_input(format!("Unknown ref_id '{ref_id}'"))
599 })?;
600 let link = page.links.iter().find(|l| l.id == link_id).ok_or_else(|| {
601 ToolError::invalid_input(format!(
602 "Link id {link_id} not found for ref_id '{ref_id}'"
603 ))
604 })?;
605 let target = link.url.clone();
606 let fetched =
607 resolve_or_fetch_page(&target, DEFAULT_OPEN_TIMEOUT_MS, context).await?;
608 click_counter += 1;
609 let click_ref = format!("{scope}turn{turn}click{click_counter}");
610 store_page(&context.state_namespace, &click_ref, fetched.clone());
611 let view = render_view(&click_ref, &fetched, 1, response_length);
612 views.push(view);
613 }
614 if !views.is_empty() {
615 output.click = Some(views);
616 }
617 }
618
619 if let Some(find_requests) = input.get("find").and_then(|v| v.as_array()) {
620 let mut finds = Vec::new();
621 for find_req in find_requests {
622 let ref_id = required_str(find_req, "ref_id")?.to_string();
623 let pattern = required_str(find_req, "pattern")?.to_string();
624 let page = get_page(&ref_id).ok_or_else(|| {
625 ToolError::invalid_input(format!("Unknown ref_id '{ref_id}'"))
626 })?;
627 let find_result = find_in_page(&ref_id, &pattern, &page, response_length);
628 finds.push(find_result);
629 }
630 if !finds.is_empty() {
631 output.find = Some(finds);
632 }
633 }
634
635 if let Some(shots) = input.get("screenshot").and_then(|v| v.as_array()) {
636 let mut screenshots = Vec::new();
637 for shot in shots {
638 let ref_id = required_str(shot, "ref_id")?.to_string();
639 let pageno = optional_u64(shot, "pageno", 0) as usize;
640 let page = get_page(&ref_id).ok_or_else(|| {
641 ToolError::invalid_input(format!("Unknown ref_id '{ref_id}'"))
642 })?;
643 let screenshot = screenshot_page(&ref_id, pageno, &page)?;
644 screenshots.push(screenshot);
645 }
646 if !screenshots.is_empty() {
647 output.screenshot = Some(screenshots);
648 }
649 }
650
651 ToolResult::json(&output).map_err(|e| ToolError::execution_failed(e.to_string()))
652 }
653 }
654
655 fn with_state<T>(f: impl FnOnce(&mut WebRunState) -> T) -> T {
656 let lock = WEB_RUN_STATE.get_or_init(|| Mutex::new(WebRunState::default()));
657 let mut state = lock
658 .lock()
659 .expect("web run state mutex should not be poisoned");
660 state.cleanup();
661 f(&mut state)
662 }
663
664 fn scoped_ref_prefix(namespace: &str) -> String {
665 let mut hasher = std::collections::hash_map::DefaultHasher::new();
666 namespace.hash(&mut hasher);
667 format!("s{:016x}_", hasher.finish())
668 }
669
670 fn store_page(namespace: &str, ref_id: &str, page: WebPage) {
671 with_state(|state| {
672 state.store_page(namespace, ref_id, page);
673 });
674 }
675
676 fn get_page(ref_id: &str) -> Option<WebPage> {
677 with_state(|state| state.get_page(ref_id))
678 }
679
680 #[cfg(test)]
681 fn reset_web_run_state() {
682 with_state(|state| {
683 *state = WebRunState::default();
684 });
685 }
686
687 #[cfg(test)]
688 fn next_turn_for_namespace(namespace: &str) -> u64 {
689 with_state(|state| state.next_turn(namespace))
690 }
691
692 async fn resolve_or_fetch_page(
693 ref_id: &str,
694 timeout_ms: u64,
695 context: &ToolContext,
696 ) -> Result<WebPage, ToolError> {
697 if let Some(page) = get_page(ref_id) {
698 return Ok(page);
699 }
700 if looks_like_url(ref_id) {
701 check_network_policy(ref_id, context)?;
702 return fetch_page(ref_id, timeout_ms).await;
703 }
704 Err(ToolError::invalid_input(format!(
705 "Unknown ref_id '{ref_id}'"
706 )))
707 }
708
709 fn looks_like_url(value: &str) -> bool {
710 value.starts_with("http://") || value.starts_with("https://")
711 }
712
713 async fn run_search(
714 query: &str,
715 max_results: usize,
716 timeout_ms: u64,
717 domains: &[String],
718 ) -> Result<(Vec<SearchEntry>, String, Option<String>), ToolError> {
719 let client = reqwest::Client::builder()
720 .timeout(Duration::from_millis(timeout_ms))
721 .user_agent(USER_AGENT)
722 .build()
723 .map_err(|e| ToolError::execution_failed(format!("Failed to build HTTP client: {e}")))?;
724
725 let encoded = url_encode(query);
726 let url = format!("https://html.duckduckgo.com/html/?q={encoded}");
727 let resp = client
728 .get(&url)
729 .header(
730 "Accept",
731 "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
732 )
733 .header("Accept-Language", "en-US,en;q=0.5")
734 .send()
735 .await
736 .map_err(|e| ToolError::execution_failed(format!("Web search request failed: {e}")))?;
737
738 let status = resp.status();
739 let body = resp
740 .text()
741 .await
742 .map_err(|e| ToolError::execution_failed(format!("Failed to read response: {e}")))?;
743
744 if !status.is_success() {
745 return Err(ToolError::execution_failed(format!(
746 "Web search failed: HTTP {}",
747 status.as_u16()
748 )));
749 }
750
751 let mut results = parse_duckduckgo_results(&body, max_results);
752 let mut source = "duckduckgo".to_string();
753 let mut warnings = Vec::new();
754
755 if results.is_empty() {
756 let duckduckgo_blocked = is_duckduckgo_challenge(&body);
757 match run_bing_search(&client, query, max_results).await {
758 Ok(fallback_results) if !fallback_results.is_empty() => {
759 results = fallback_results;
760 source = "bing".to_string();
761 warnings.push(if duckduckgo_blocked {
762 "DuckDuckGo returned a bot challenge; used Bing fallback".to_string()
763 } else {
764 "DuckDuckGo returned no parseable results; used Bing fallback".to_string()
765 });
766 }
767 Ok(_) if duckduckgo_blocked => {
768 return Err(ToolError::execution_failed(
769 "DuckDuckGo returned a bot challenge and Bing fallback returned no results",
770 ));
771 }
772 Err(err) if duckduckgo_blocked => {
773 return Err(ToolError::execution_failed(format!(
774 "DuckDuckGo returned a bot challenge and Bing fallback failed: {err}"
775 )));
776 }
777 Ok(_) | Err(_) => {}
778 }
779 }
780
781 if !domains.is_empty() {
782 let before = results.len();
783 results.retain(|entry| domain_matches(&entry.url, domains));
784 if before != results.len() {
785 warnings.push("Filtered search results by domain list".to_string());
786 }
787 }
788
789 Ok((
790 results,
791 source,
792 if warnings.is_empty() {
793 None
794 } else {
795 Some(warnings.join("; "))
796 },
797 ))
798 }
799
800 async fn run_bing_search(
801 client: &reqwest::Client,
802 query: &str,
803 max_results: usize,
804 ) -> Result<Vec<SearchEntry>, ToolError> {
805 let encoded = url_encode(query);
806 let url = format!("https://www.bing.com/search?q={encoded}");
807 let resp = client
808 .get(&url)
809 .header(
810 "Accept",
811 "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
812 )
813 .header("Accept-Language", "en-US,en;q=0.9")
814 .send()
815 .await
816 .map_err(|e| ToolError::execution_failed(format!("Bing fallback request failed: {e}")))?;
817
818 let status = resp.status();
819 let body = resp.text().await.map_err(|e| {
820 ToolError::execution_failed(format!("Failed to read Bing fallback response: {e}"))
821 })?;
822
823 if !status.is_success() {
824 return Err(ToolError::execution_failed(format!(
825 "Bing fallback failed: HTTP {}",
826 status.as_u16()
827 )));
828 }
829
830 Ok(parse_bing_results(&body, max_results))
831 }
832
833 fn domain_matches(url: &str, domains: &[String]) -> bool {
834 if domains.is_empty() {
835 return true;
836 }
837 let Ok(parsed) = reqwest::Url::parse(url) else {
838 return false;
839 };
840 let Some(host) = parsed.host_str() else {
841 return false;
842 };
843 domains.iter().any(|domain| {
844 let domain = domain.trim_start_matches("www.");
845 host == domain || host.ends_with(&format!(".{domain}"))
846 })
847 }
848
849 #[derive(Debug, Clone, Deserialize)]
850 struct DuckDuckGoImageResponse {
851 #[serde(default)]
852 results: Vec<DuckDuckGoImageResult>,
853 }
854
855 #[derive(Debug, Clone, Deserialize)]
856 struct DuckDuckGoImageResult {
857 image: String,
858 #[serde(default)]
859 thumbnail: Option<String>,
860 #[serde(default)]
861 title: Option<String>,
862 #[serde(default)]
863 url: Option<String>,
864 #[serde(default)]
865 source: Option<String>,
866 #[serde(default)]
867 width: Option<u32>,
868 #[serde(default)]
869 height: Option<u32>,
870 }
871
872 fn extract_duckduckgo_vqd(html: &str) -> Option<String> {
873 let html = html.trim();
874 if html.is_empty() {
875 return None;
876 }
877
878 for (prefix, suffix) in [("vqd='", "'"), ("vqd=\"", "\"")] {
879 if let Some(start) = html.find(prefix) {
880 let rest = &html[start + prefix.len()..];
881 if let Some(end) = rest.find(suffix) {
882 let token = rest[..end].trim();
883 if !token.is_empty() {
884 return Some(token.to_string());
885 }
886 }
887 }
888 }
889
890 // Fallback: look for `vqd=` and accept a conservative token charset.
891 if let Some(start) = html.find("vqd=") {
892 let rest = &html[start + 4..];
893 let mut token = String::new();
894 for ch in rest.chars() {
895 if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
896 token.push(ch);
897 } else {
898 break;
899 }
900 }
901 if !token.is_empty() {
902 return Some(token);
903 }
904 }
905
906 None
907 }
908
909 async fn run_image_search(
910 query: &str,
911 max_results: usize,
912 timeout_ms: u64,
913 domains: &[String],
914 ) -> Result<(Vec<ImageResultEntry>, Option<String>), ToolError> {
915 let client = reqwest::Client::builder()
916 .timeout(Duration::from_millis(timeout_ms))
917 .user_agent(USER_AGENT)
918 .build()
919 .map_err(|e| ToolError::execution_failed(format!("Failed to build HTTP client: {e}")))?;
920
921 // Step 1: fetch the HTML page to obtain the `vqd` token used by the images API.
922 let encoded = url_encode(query);
923 let seed_url = format!("https://duckduckgo.com/?q={encoded}&iax=images&ia=images");
924 let seed_resp = client
925 .get(&seed_url)
926 .header(
927 "Accept",
928 "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
929 )
930 .header("Accept-Language", "en-US,en;q=0.5")
931 .send()
932 .await
933 .map_err(|e| {
934 ToolError::execution_failed(format!("Image search seed request failed: {e}"))
935 })?;
936
937 let seed_status = seed_resp.status();
938 let seed_body = seed_resp.text().await.map_err(|e| {
939 ToolError::execution_failed(format!("Failed to read image seed response: {e}"))
940 })?;
941
942 if !seed_status.is_success() {
943 return Err(ToolError::execution_failed(format!(
944 "Image search seed request failed: HTTP {}",
945 seed_status.as_u16()
946 )));
947 }
948
949 let vqd = extract_duckduckgo_vqd(&seed_body).ok_or_else(|| {
950 ToolError::execution_failed("Failed to extract DuckDuckGo image token (vqd)")
951 })?;
952
953 // Step 2: query the DuckDuckGo images JSON endpoint.
954 let api_url = format!("https://duckduckgo.com/i.js?l=us-en&o=json&q={encoded}&vqd={vqd}&p=1");
955 let api_resp = client
956 .get(&api_url)
957 .header("Accept", "application/json")
958 .header("Referer", "https://duckduckgo.com/")
959 .send()
960 .await
961 .map_err(|e| ToolError::execution_failed(format!("Image search request failed: {e}")))?;
962
963 let api_status = api_resp.status();
964 let api_body = api_resp
965 .text()
966 .await
967 .map_err(|e| ToolError::execution_failed(format!("Failed to read image response: {e}")))?;
968
969 if !api_status.is_success() {
970 return Err(ToolError::execution_failed(format!(
971 "Image search failed: HTTP {}",
972 api_status.as_u16()
973 )));
974 }
975
976 let parsed: DuckDuckGoImageResponse = serde_json::from_str(&api_body).map_err(|e| {
977 ToolError::execution_failed(format!("Failed to parse image search JSON: {e}"))
978 })?;
979
980 let mut results = parsed
981 .results
982 .into_iter()
983 .filter(|item| !item.image.trim().is_empty())
984 .map(|item| ImageResultEntry {
985 image: item.image,
986 thumbnail: item.thumbnail,
987 title: item.title,
988 url: item.url,
989 source: item.source,
990 width: item.width,
991 height: item.height,
992 })
993 .collect::<Vec<_>>();
994
995 // Domain filter is applied to the source page URL when available.
996 let warning = if !domains.is_empty() {
997 let before = results.len();
998 results.retain(|entry| match entry.url.as_deref() {
999 Some(url) => domain_matches(url, domains),
1000 None => true,
1001 });
1002 if before != results.len() {
1003 Some("Filtered image results by domain list".to_string())
1004 } else {
1005 None
1006 }
1007 } else {
1008 None
1009 };
1010
1011 results.truncate(max_results);
1012 Ok((results, warning))
1013 }
1014
1015 fn page_from_search(query: &str, results: &[SearchEntry]) -> WebPage {
1016 let mut lines = Vec::new();
1017 let mut links = Vec::new();
1018
1019 lines.push(format!("Search results for: {query}"));
1020 for (idx, entry) in results.iter().enumerate() {
1021 let id = idx + 1;
1022 links.push(WebLink {
1023 id,
1024 url: entry.url.clone(),
1025 text: entry.title.clone(),
1026 });
1027 lines.push(format!("{}. [{}] {}", id, id, entry.title));
1028 if let Some(snippet) = entry.snippet.as_ref()
1029 && !snippet.trim().is_empty()
1030 {
1031 lines.push(format!(" {snippet}"));
1032 }
1033 lines.push(format!(" {url}", url = entry.url));
1034 }
1035
1036 WebPage {
1037 url: "https://html.duckduckgo.com/html/".to_string(),
1038 title: Some("Search Results".to_string()),
1039 content_type: Some("text/html".to_string()),
1040 lines,
1041 links,
1042 pdf_pages: None,
1043 }
1044 }
1045
1046 /// Check network policy for a URL before fetching.
1047 /// Returns an error if the policy denies access.
1048 fn check_network_policy(url: &str, context: &ToolContext) -> Result<(), ToolError> {
1049 let Some(decider) = context.network_policy.as_ref() else {
1050 return Ok(());
1051 };
1052 let Some(host) = host_from_url(url) else {
1053 return Ok(());
1054 };
1055 match decider.evaluate(&host, "web_run") {
1056 Decision::Allow => Ok(()),
1057 Decision::Deny => Err(ToolError::permission_denied(format!(
1058 "network call to '{host}' blocked by network policy"
1059 ))),
1060 Decision::Prompt => Err(ToolError::permission_denied(format!(
1061 "network call to '{host}' requires approval; \
1062 re-run after `/network allow {host}` or set network.default = \"allow\" in config"
1063 ))),
1064 }
1065 }
1066
1067 async fn fetch_page(url: &str, timeout_ms: u64) -> Result<WebPage, ToolError> {
1068 let client = reqwest::Client::builder()
1069 .timeout(Duration::from_millis(timeout_ms))
1070 .user_agent(USER_AGENT)
1071 .build()
1072 .map_err(|e| ToolError::execution_failed(format!("Failed to build HTTP client: {e}")))?;
1073
1074 let resp = client
1075 .get(url)
1076 .header(
1077 "Accept",
1078 "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
1079 )
1080 .header("Accept-Language", "en-US,en;q=0.5")
1081 .send()
1082 .await
1083 .map_err(|e| ToolError::execution_failed(format!("Web request failed: {e}")))?;
1084
1085 let status = resp.status();
1086 let content_type = resp
1087 .headers()
1088 .get(reqwest::header::CONTENT_TYPE)
1089 .and_then(|v| v.to_str().ok())
1090 .map(|s| s.to_string());
1091 let bytes = resp
1092 .bytes()
1093 .await
1094 .map_err(|e| ToolError::execution_failed(format!("Failed to read response: {e}")))?;
1095
1096 if !status.is_success() {
1097 return Err(ToolError::execution_failed(format!(
1098 "Web request failed: HTTP {}",
1099 status.as_u16()
1100 )));
1101 }
1102
1103 if is_pdf(&content_type, url) {
1104 return parse_pdf_page(url, content_type, &bytes);
1105 }
1106
1107 let body = String::from_utf8_lossy(&bytes).to_string();
1108 let (lines, links, title) = parse_html(&body, url);
1109
1110 Ok(WebPage {
1111 url: url.to_string(),
1112 title,
1113 content_type,
1114 lines,
1115 links,
1116 pdf_pages: None,
1117 })
1118 }
1119
1120 fn is_pdf(content_type: &Option<String>, url: &str) -> bool {
1121 if let Some(ct) = content_type
1122 && ct.to_lowercase().contains("application/pdf")
1123 {
1124 return true;
1125 }
1126 url.to_lowercase().ends_with(".pdf")
1127 }
1128
1129 fn parse_pdf_page(
1130 url: &str,
1131 content_type: Option<String>,
1132 bytes: &[u8],
1133 ) -> Result<WebPage, ToolError> {
1134 let text = pdf_extract_text(bytes)?;
1135 let pages = split_pdf_pages(&text);
1136 let lines = pages.first().cloned().unwrap_or_default();
1137
1138 Ok(WebPage {
1139 url: url.to_string(),
1140 title: Some("PDF Document".to_string()),
1141 content_type,
1142 lines,
1143 links: Vec::new(),
1144 pdf_pages: Some(pages),
1145 })
1146 }
1147
1148 fn pdf_extract_text(bytes: &[u8]) -> Result<String, ToolError> {
1149 pdf_extract::extract_text_from_mem(bytes)
1150 .map_err(|e| ToolError::execution_failed(format!("PDF extract failed: {e}")))
1151 }
1152
1153 fn split_pdf_pages(text: &str) -> Vec<Vec<String>> {
1154 let raw_pages: Vec<&str> = text.split('\x0C').collect();
1155 raw_pages
1156 .iter()
1157 .map(|page| {
1158 page.lines()
1159 .map(|line| line.trim())
1160 .filter(|line| !line.is_empty())
1161 .map(|line| line.to_string())
1162 .collect::<Vec<_>>()
1163 })
1164 .collect()
1165 }
1166
1167 fn render_view(
1168 ref_id: &str,
1169 page: &WebPage,
1170 lineno: usize,
1171 response: ResponseLength,
1172 ) -> PageViewResult {
1173 let total = page.lines.len();
1174 let view_lines = response.view_lines();
1175 let start = if total == 0 {
1176 1
1177 } else if lineno > total {
1178 total.saturating_sub(view_lines.saturating_sub(1)).max(1)
1179 } else {
1180 lineno
1181 };
1182 let end = if total == 0 {
1183 0
1184 } else {
1185 (start + view_lines - 1).min(total)
1186 };
1187
1188 let content = if total == 0 {
1189 "(no content)".to_string()
1190 } else {
1191 render_lines(&page.lines, start, end)
1192 };
1193
1194 PageViewResult {
1195 ref_id: ref_id.to_string(),
1196 url: page.url.clone(),
1197 title: page.title.clone(),
1198 content_type: page.content_type.clone(),
1199 line_start: start,
1200 line_end: end,
1201 total_lines: total,
1202 content,
1203 links: page.links.clone(),
1204 }
1205 }
1206
1207 fn render_lines(lines: &[String], start: usize, end: usize) -> String {
1208 lines
1209 .iter()
1210 .enumerate()
1211 .filter_map(|(idx, line)| {
1212 let line_no = idx + 1;
1213 if line_no < start || line_no > end {
1214 return None;
1215 }
1216 Some(format!("{:>4} {}", line_no, line))
1217 })
1218 .collect::<Vec<_>>()
1219 .join("\n")
1220 }
1221
1222 fn find_in_page(
1223 ref_id: &str,
1224 pattern: &str,
1225 page: &WebPage,
1226 response: ResponseLength,
1227 ) -> FindResult {
1228 let needle = pattern.to_lowercase();
1229 let mut matches = Vec::new();
1230 for (idx, line) in page.lines.iter().enumerate() {
1231 if line.to_lowercase().contains(&needle) {
1232 matches.push(FindMatch {
1233 line: idx + 1,
1234 text: line.clone(),
1235 });
1236 }
1237 if matches.len() >= response.max_find_matches() {
1238 break;
1239 }
1240 }
1241
1242 FindResult {
1243 ref_id: ref_id.to_string(),
1244 pattern: pattern.to_string(),
1245 count: matches.len(),
1246 matches,
1247 }
1248 }
1249
1250 fn screenshot_page(
1251 ref_id: &str,
1252 pageno: usize,
1253 page: &WebPage,
1254 ) -> Result<ScreenshotResult, ToolError> {
1255 let pages = page
1256 .pdf_pages
1257 .as_ref()
1258 .ok_or_else(|| ToolError::invalid_input("screenshot is only supported for PDF pages"))?;
1259 if pages.is_empty() {
1260 return Err(ToolError::execution_failed("PDF has no pages"));
1261 }
1262 if pageno >= pages.len() {
1263 return Err(ToolError::invalid_input(format!(
1264 "pageno {pageno} out of range (0..{max})",
1265 max = pages.len().saturating_sub(1)
1266 )));
1267 }
1268 let content = pages[pageno].join("\n");
1269 Ok(ScreenshotResult {
1270 ref_id: ref_id.to_string(),
1271 pageno,
1272 total_pages: pages.len(),
1273 content,
1274 })
1275 }
1276
1277 // === HTML Parsing ===
1278
1279 static ANCHOR_RE: OnceLock<Regex> = OnceLock::new();
1280 static TAG_RE: OnceLock<Regex> = OnceLock::new();
1281 static BLOCK_RE: OnceLock<Regex> = OnceLock::new();
1282 static SCRIPT_RE: OnceLock<Regex> = OnceLock::new();
1283 static STYLE_RE: OnceLock<Regex> = OnceLock::new();
1284 static TITLE_RE: OnceLock<Regex> = OnceLock::new();
1285 static SNIPPET_RE: OnceLock<Regex> = OnceLock::new();
1286 static SEARCH_TITLE_RE: OnceLock<Regex> = OnceLock::new();
1287 static BING_RESULT_RE: OnceLock<Regex> = OnceLock::new();
1288 static BING_TITLE_RE: OnceLock<Regex> = OnceLock::new();
1289 static BING_SNIPPET_RE: OnceLock<Regex> = OnceLock::new();
1290
1291 fn get_anchor_re() -> &'static Regex {
1292 ANCHOR_RE.get_or_init(|| {
1293 Regex::new(r#"(?is)<a\s+[^>]*href\s*=\s*['\"]([^'\"]+)['\"][^>]*>(.*?)</a>"#)
1294 .expect("anchor regex")
1295 })
1296 }
1297
1298 fn get_tag_re() -> &'static Regex {
1299 TAG_RE.get_or_init(|| Regex::new(r"<[^>]+>").expect("tag regex"))
1300 }
1301
1302 fn get_block_re() -> &'static Regex {
1303 BLOCK_RE.get_or_init(|| {
1304 Regex::new(r"(?is)</?(p|div|li|ul|ol|br|h[1-6]|tr|td|th|table|section|article)[^>]*>")
1305 .expect("block regex")
1306 })
1307 }
1308
1309 fn get_script_re() -> &'static Regex {
1310 SCRIPT_RE.get_or_init(|| Regex::new(r"(?is)<script[^>]*>.*?</script>").unwrap())
1311 }
1312
1313 fn get_style_re() -> &'static Regex {
1314 STYLE_RE.get_or_init(|| Regex::new(r"(?is)<style[^>]*>.*?</style>").unwrap())
1315 }
1316
1317 fn get_title_re() -> &'static Regex {
1318 TITLE_RE.get_or_init(|| Regex::new(r"(?is)<title[^>]*>(.*?)</title>").unwrap())
1319 }
1320
1321 fn get_search_title_re() -> &'static Regex {
1322 SEARCH_TITLE_RE.get_or_init(|| {
1323 Regex::new(r#"<a[^>]*class=\"result__a\"[^>]*href=\"([^\"]+)\"[^>]*>(.*?)</a>"#)
1324 .expect("title regex pattern is valid")
1325 })
1326 }
1327
1328 fn get_search_snippet_re() -> &'static Regex {
1329 SNIPPET_RE.get_or_init(|| {
1330 Regex::new(
1331 r#"<a[^>]*class=\"result__snippet\"[^>]*>(.*?)</a>|<div[^>]*class=\"result__snippet\"[^>]*>(.*?)</div>"#,
1332 )
1333 .expect("snippet regex pattern is valid")
1334 })
1335 }
1336
1337 fn get_bing_result_re() -> &'static Regex {
1338 BING_RESULT_RE.get_or_init(|| {
1339 Regex::new(r#"(?is)<li[^>]*class=\"[^\"]*\bb_algo\b[^\"]*\"[^>]*>(.*?)</li>"#)
1340 .expect("bing result regex pattern is valid")
1341 })
1342 }
1343
1344 fn get_bing_title_re() -> &'static Regex {
1345 BING_TITLE_RE.get_or_init(|| {
1346 Regex::new(r#"(?is)<h2[^>]*>.*?<a[^>]*href=\"([^\"]+)\"[^>]*>(.*?)</a>"#)
1347 .expect("bing title regex pattern is valid")
1348 })
1349 }
1350
1351 fn get_bing_snippet_re() -> &'static Regex {
1352 BING_SNIPPET_RE.get_or_init(|| {
1353 Regex::new(r#"(?is)<div[^>]*class=\"[^\"]*\bb_caption\b[^\"]*\"[^>]*>.*?<p[^>]*>(.*?)</p>"#)
1354 .expect("bing snippet regex pattern is valid")
1355 })
1356 }
1357
1358 fn parse_html(html: &str, base_url: &str) -> (Vec<String>, Vec<WebLink>, Option<String>) {
1359 let title = extract_title(html);
1360 let without_scripts = get_script_re().replace_all(html, "").to_string();
1361 let without_styles = get_style_re().replace_all(&without_scripts, "").to_string();
1362
1363 let (with_links, links) = replace_links(&without_styles, base_url);
1364 let with_breaks = get_block_re().replace_all(&with_links, "\n").to_string();
1365 let without_tags = get_tag_re().replace_all(&with_breaks, "").to_string();
1366 let decoded = decode_html_entities(&without_tags);
1367
1368 let mut lines = Vec::new();
1369 for line in decoded.lines() {
1370 let trimmed = normalize_whitespace(line);
1371 if trimmed.is_empty() {
1372 continue;
1373 }
1374 for wrapped in wrap_line(&trimmed, ResponseLength::Medium.wrap_width()) {
1375 lines.push(wrapped);
1376 }
1377 }
1378
1379 (lines, links, title)
1380 }
1381
1382 fn extract_title(html: &str) -> Option<String> {
1383 let re = get_title_re();
1384 let cap = re.captures(html)?;
1385 let raw = cap.get(1)?.as_str();
1386 let cleaned = normalize_whitespace(&decode_html_entities(raw));
1387 if cleaned.is_empty() {
1388 None
1389 } else {
1390 Some(cleaned)
1391 }
1392 }
1393
1394 fn replace_links(html: &str, base_url: &str) -> (String, Vec<WebLink>) {
1395 let re = get_anchor_re();
1396 let mut links = Vec::new();
1397 let mut output = String::with_capacity(html.len());
1398 let mut last = 0;
1399
1400 for cap in re.captures_iter(html) {
1401 let Some(full) = cap.get(0) else { continue };
1402 let Some(href) = cap.get(1) else { continue };
1403 let Some(text_match) = cap.get(2) else {
1404 continue;
1405 };
1406
1407 output.push_str(&html[last..full.start()]);
1408 let text = normalize_whitespace(&strip_tags(text_match.as_str()));
1409 let resolved = resolve_url(base_url, href.as_str());
1410 if !text.is_empty() {
1411 let id = links.len() + 1;
1412 links.push(WebLink {
1413 id,
1414 url: resolved.clone(),
1415 text: text.clone(),
1416 });
1417 output.push_str(&format!("[{}] {}", id, text));
1418 } else {
1419 output.push_str(&resolved);
1420 }
1421 last = full.end();
1422 }
1423
1424 output.push_str(&html[last..]);
1425 (output, links)
1426 }
1427
1428 fn resolve_url(base: &str, href: &str) -> String {
1429 if href.starts_with("http://") || href.starts_with("https://") {
1430 return href.to_string();
1431 }
1432 if href.starts_with("//") {
1433 return format!("https:{href}");
1434 }
1435 if let Ok(base_url) = reqwest::Url::parse(base)
1436 && let Ok(joined) = base_url.join(href)
1437 {
1438 return joined.to_string();
1439 }
1440 href.to_string()
1441 }
1442
1443 fn strip_tags(text: &str) -> String {
1444 get_tag_re().replace_all(text, "").to_string()
1445 }
1446
1447 fn normalize_whitespace(text: &str) -> String {
1448 text.split_whitespace().collect::<Vec<_>>().join(" ")
1449 }
1450
1451 fn wrap_line(text: &str, width: usize) -> Vec<String> {
1452 if text.len() <= width {
1453 return vec![text.to_string()];
1454 }
1455 let mut lines = Vec::new();
1456 let mut current = String::new();
1457 for word in text.split_whitespace() {
1458 if current.is_empty() {
1459 current.push_str(word);
1460 } else if current.len() + word.len() < width {
1461 current.push(' ');
1462 current.push_str(word);
1463 } else {
1464 lines.push(current);
1465 current = word.to_string();
1466 }
1467 }
1468 if !current.is_empty() {
1469 lines.push(current);
1470 }
1471 lines
1472 }
1473
1474 fn decode_html_entities(text: &str) -> String {
1475 text.replace("&amp;", "&")
1476 .replace("&quot;", "\"")
1477 .replace("&#39;", "'")
1478 .replace("&#x27;", "'")
1479 .replace("&lt;", "<")
1480 .replace("&gt;", ">")
1481 .replace("&nbsp;", " ")
1482 }
1483
1484 fn parse_duckduckgo_results(html: &str, max_results: usize) -> Vec<SearchEntry> {
1485 let title_re = get_search_title_re();
1486 let snippet_re = get_search_snippet_re();
1487 let snippets: Vec<String> = snippet_re
1488 .captures_iter(html)
1489 .filter_map(|cap| cap.get(1).or_else(|| cap.get(2)))
1490 .map(|m| normalize_whitespace(&decode_html_entities(&strip_tags(m.as_str()))))
1491 .collect();
1492
1493 let mut results = Vec::new();
1494 for (idx, cap) in title_re.captures_iter(html).enumerate() {
1495 if results.len() >= max_results {
1496 break;
1497 }
1498 let href = cap.get(1).map(|m| m.as_str()).unwrap_or("");
1499 let title_raw = cap.get(2).map(|m| m.as_str()).unwrap_or("");
1500 let title = normalize_whitespace(&decode_html_entities(&strip_tags(title_raw)));
1501 if title.is_empty() {
1502 continue;
1503 }
1504 let url = normalize_search_url(href);
1505 let snippet = snippets
1506 .get(idx)
1507 .map(|s| s.to_string())
1508 .filter(|s| !s.is_empty());
1509
1510 results.push(SearchEntry {
1511 title,
1512 url,
1513 snippet,
1514 });
1515 }
1516
1517 results
1518 }
1519
1520 fn is_duckduckgo_challenge(html: &str) -> bool {
1521 html.contains("anomaly-modal") || html.contains("Unfortunately, bots use DuckDuckGo too")
1522 }
1523
1524 fn parse_bing_results(html: &str, max_results: usize) -> Vec<SearchEntry> {
1525 let mut results = Vec::new();
1526 for cap in get_bing_result_re().captures_iter(html) {
1527 if results.len() >= max_results {
1528 break;
1529 }
1530 let Some(block) = cap.get(1).map(|m| m.as_str()) else {
1531 continue;
1532 };
1533 let Some(title_cap) = get_bing_title_re().captures(block) else {
1534 continue;
1535 };
1536 let href = title_cap.get(1).map(|m| m.as_str()).unwrap_or("");
1537 let title_raw = title_cap.get(2).map(|m| m.as_str()).unwrap_or("");
1538 let title = normalize_whitespace(&decode_html_entities(&strip_tags(title_raw)));
1539 if title.is_empty() {
1540 continue;
1541 }
1542 let snippet = get_bing_snippet_re()
1543 .captures(block)
1544 .and_then(|snippet_cap| snippet_cap.get(1))
1545 .map(|m| normalize_whitespace(&decode_html_entities(&strip_tags(m.as_str()))))
1546 .filter(|s| !s.is_empty());
1547
1548 results.push(SearchEntry {
1549 title,
1550 url: normalize_bing_url(href),
1551 snippet,
1552 });
1553 }
1554
1555 results
1556 }
1557
1558 fn normalize_search_url(href: &str) -> String {
1559 if let Some(uddg) = extract_query_param(href, "uddg") {
1560 let decoded = percent_decode(&uddg);
1561 if !decoded.is_empty() {
1562 return decoded;
1563 }
1564 }
1565 if href.starts_with("//") {
1566 return format!("https:{href}");
1567 }
1568 if href.starts_with('/') {
1569 return format!("https://duckduckgo.com{href}");
1570 }
1571 href.to_string()
1572 }
1573
1574 fn normalize_bing_url(href: &str) -> String {
1575 if let Some(encoded) = extract_query_param(href, "u") {
1576 let decoded = percent_decode(&encoded);
1577 let token = decoded.strip_prefix("a1").unwrap_or(&decoded);
1578 let mut padded = token.replace('-', "+").replace('_', "/");
1579 while !padded.len().is_multiple_of(4) {
1580 padded.push('=');
1581 }
1582 if let Ok(bytes) = general_purpose::STANDARD.decode(padded)
1583 && let Ok(url) = String::from_utf8(bytes)
1584 && looks_like_url(&url)
1585 {
1586 return url;
1587 }
1588 }
1589 if href.starts_with("//") {
1590 return format!("https:{href}");
1591 }
1592 if href.starts_with('/') {
1593 return format!("https://www.bing.com{href}");
1594 }
1595 href.to_string()
1596 }
1597
1598 fn extract_query_param(url: &str, key: &str) -> Option<String> {
1599 let query_start = url.find('?')?;
1600 let query = &url[query_start + 1..];
1601 for part in query.split('&') {
1602 let (k, v) = part.split_once('=')?;
1603 if k == key {
1604 return Some(v.to_string());
1605 }
1606 }
1607 None
1608 }
1609
1610 fn percent_decode(input: &str) -> String {
1611 let mut out = Vec::with_capacity(input.len());
1612 let bytes = input.as_bytes();
1613 let mut idx = 0;
1614 while idx < bytes.len() {
1615 if bytes[idx] == b'%'
1616 && idx + 2 < bytes.len()
1617 && let Ok(hex) = std::str::from_utf8(&bytes[idx + 1..idx + 3])
1618 && let Ok(val) = u8::from_str_radix(hex, 16)
1619 {
1620 out.push(val);
1621 idx += 3;
1622 continue;
1623 }
1624 out.push(bytes[idx]);
1625 idx += 1;
1626 }
1627 String::from_utf8_lossy(&out).into_owned()
1628 }
1629
1630 fn url_encode(input: &str) -> String {
1631 crate::utils::url_encode(input)
1632 }
1633
1634 // === Tests ===
1635
1636 #[cfg(test)]
1637 mod tests {
1638 use super::*;
1639 use std::path::PathBuf;
1640
1641 fn sample_page(url: &str) -> WebPage {
1642 WebPage {
1643 url: url.to_string(),
1644 title: Some("Example".to_string()),
1645 content_type: Some("text/html".to_string()),
1646 lines: vec!["example line".to_string()],
1647 links: Vec::new(),
1648 pdf_pages: None,
1649 }
1650 }
1651
1652 #[test]
1653 fn html_link_parsing_extracts_links() {
1654 let html = r#"
1655 <html><body>
1656 <p>Hello <a href="https://example.com">Example</a> world.</p>
1657 </body></html>
1658 "#;
1659 let (lines, links, title) = parse_html(html, "https://example.com");
1660 assert!(title.is_none());
1661 assert_eq!(links.len(), 1);
1662 assert_eq!(links[0].url, "https://example.com");
1663 assert!(lines.iter().any(|line| line.contains("Example")));
1664 }
1665
1666 #[test]
1667 fn wrap_line_splits_long_lines() {
1668 let line = "This is a long line that should wrap cleanly at word boundaries";
1669 let wrapped = wrap_line(line, 20);
1670 assert!(wrapped.len() > 1);
1671 assert!(wrapped.iter().all(|l| l.len() <= 20));
1672 }
1673
1674 #[test]
1675 fn extracts_duckduckgo_vqd_token() {
1676 let html_single = "<script>var x = {vqd='3-1234567890'};</script>";
1677 assert_eq!(
1678 extract_duckduckgo_vqd(html_single),
1679 Some("3-1234567890".to_string())
1680 );
1681
1682 let html_double = "<script>var x = {vqd=\"3-abcdef\"};</script>";
1683 assert_eq!(
1684 extract_duckduckgo_vqd(html_double),
1685 Some("3-abcdef".to_string())
1686 );
1687
1688 let html_plain = "https://duckduckgo.com/?q=test&vqd=3-xyz_123&ia=images";
1689 assert_eq!(
1690 extract_duckduckgo_vqd(html_plain),
1691 Some("3-xyz_123".to_string())
1692 );
1693 }
1694
1695 #[test]
1696 fn parses_bing_results_and_decodes_redirect_url() {
1697 let html = r#"
1698 <ol>
1699 <li class="b_algo">
1700 <h2><a href="https://www.bing.com/ck/a?u=a1aHR0cHM6Ly9leGFtcGxlLmNvbS9wYXRoP3E9MQ">Example &amp; Result</a></h2>
1701 <div class="b_caption"><p>A <strong>useful</strong> snippet.</p></div>
1702 </li>
1703 </ol>
1704 "#;
1705
1706 let results = parse_bing_results(html, 5);
1707
1708 assert_eq!(results.len(), 1);
1709 assert_eq!(results[0].title, "Example & Result");
1710 assert_eq!(results[0].url, "https://example.com/path?q=1");
1711 assert_eq!(results[0].snippet.as_deref(), Some("A useful snippet."));
1712 }
1713
1714 #[test]
1715 fn percent_decode_handles_utf8_multibyte_sequences() {
1716 // Percent-encoded CJK: %E4%B8%AA%E4%BA%BA = 个人 (each glyph is 3 UTF-8 bytes).
1717 assert_eq!(percent_decode("Hello %E4%B8%AA%E4%BA%BA"), "Hello 个人");
1718 assert_eq!(percent_decode("%E7%B4%A0%E6%9D%90"), "素材");
1719 // Percent-encoded UTF-8 inside a URL path (DuckDuckGo `uddg=` redirect shape).
1720 assert_eq!(
1721 percent_decode("https://example.com/%E9%A1%B5%E9%9D%A2"),
1722 "https://example.com/页面"
1723 );
1724 // Raw UTF-8 in the input passes through unchanged.
1725 assert_eq!(percent_decode("查询 keyword"), "查询 keyword");
1726 // ASCII-only inputs preserve existing behavior; `+` stays literal.
1727 assert_eq!(percent_decode("foo+bar%20baz"), "foo+bar baz");
1728 }
1729
1730 #[test]
1731 fn scoped_ref_prefix_is_session_specific() {
1732 reset_web_run_state();
1733 let alpha = scoped_ref_prefix("session-alpha");
1734 let beta = scoped_ref_prefix("session-beta");
1735
1736 assert_ne!(alpha, beta);
1737 assert!(alpha.starts_with('s'));
1738 assert!(alpha.ends_with('_'));
1739 assert_eq!(alpha.len(), 18);
1740 }
1741
1742 #[test]
1743 fn stored_pages_do_not_cross_scoped_sessions() {
1744 reset_web_run_state();
1745 let shared_suffix = "turn1search1";
1746 let ref_alpha = format!("{}{}", scoped_ref_prefix("session-alpha"), shared_suffix);
1747 let ref_beta = format!("{}{}", scoped_ref_prefix("session-beta"), shared_suffix);
1748
1749 store_page(
1750 "session-alpha",
1751 &ref_alpha,
1752 sample_page("https://example.com/alpha"),
1753 );
1754
1755 assert!(get_page(&ref_alpha).is_some());
1756 assert!(get_page(&ref_beta).is_none());
1757 }
1758
1759 #[test]
1760 fn turn_counters_are_scoped_per_session() {
1761 reset_web_run_state();
1762
1763 assert_eq!(next_turn_for_namespace("session-alpha"), 0);
1764 assert_eq!(next_turn_for_namespace("session-alpha"), 1);
1765 assert_eq!(next_turn_for_namespace("session-beta"), 0);
1766 }
1767
1768 #[test]
1769 fn stale_session_pages_are_evicted() {
1770 reset_web_run_state();
1771 let namespace = "session-alpha";
1772 let ref_id = format!("{}turn0search1", scoped_ref_prefix(namespace));
1773 store_page(namespace, &ref_id, sample_page("https://example.com/alpha"));
1774
1775 // On Windows, Instant's epoch is system boot. If the CI runner has
1776 // been up for less than WEB_RUN_SESSION_TTL the subtraction would
1777 // underflow, so we skip the test in that case.
1778 let stale = WEB_RUN_SESSION_TTL + Duration::from_secs(1);
1779 let can_test = with_state(|state| {
1780 let session = state
1781 .sessions
1782 .get_mut(namespace)
1783 .expect("session should exist");
1784 match Instant::now().checked_sub(stale) {
1785 Some(past) => {
1786 session.last_access = past;
1787 true
1788 }
1789 None => false,
1790 }
1791 });
1792 if !can_test {
1793 // System uptime shorter than session TTL; can't test eviction.
1794 return;
1795 }
1796
1797 let _ = next_turn_for_namespace("session-beta");
1798
1799 assert!(get_page(&ref_id).is_none());
1800 }
1801
1802 #[test]
1803 fn direct_urls_remain_compatible_open_refs() {
1804 assert!(looks_like_url("https://example.com"));
1805 assert!(looks_like_url("http://example.com"));
1806 assert!(!looks_like_url("turn0search0"));
1807 }
1808
1809 #[test]
1810 fn network_policy_denies_direct_open_url() {
1811 use crate::network_policy::{Decision, NetworkPolicy, NetworkPolicyDecider};
1812
1813 let policy = NetworkPolicy {
1814 default: Decision::Deny.into(),
1815 allow: vec!["api.deepseek.com".to_string()],
1816 deny: vec![],
1817 audit: false,
1818 };
1819 let decider = NetworkPolicyDecider::new(policy, None);
1820 let ctx = ToolContext::new(PathBuf::from(".")).with_network_policy(decider);
1821
1822 let err = check_network_policy("https://example.com/private", &ctx)
1823 .expect_err("blocked host should fail");
1824 assert!(format!("{err}").contains("blocked by network policy"));
1825 }
1826 }
1827
1827 lines RUST