Skip to main content

spider_browser/ai/
agent.rs

1//! Autonomous multi-step agent and action executor.
2//!
3//! Ported from TypeScript `ai/agent.ts`.
4//!
5//! Loop: screenshot -> HTML -> LLM -> parse plan -> execute actions -> repeat.
6
7use crate::ai::llm_provider::{LLMConfig, LLMContent, LLMMessage, LLMProvider, LLMRole};
8use crate::ai::prompts::{build_system_prompt, build_user_message};
9use crate::errors::{Result, SpiderError};
10use crate::events::SpiderEventEmitter;
11use crate::protocol::protocol_adapter::ProtocolAdapter;
12use serde::{Deserialize, Serialize};
13use serde_json::{json, Value};
14use tokio::time::{sleep, Duration};
15use tracing::{info, warn};
16
17/// Scope of an agent run.
18///
19/// - [`AgentScope::Browser`] — full browser control (default; identical to
20///   prior behavior).
21/// - [`AgentScope::Page`] — restricted to the current page/tab: the system
22///   prompt gains a page-scope addendum and a best-effort guardrail script
23///   blocks `window.open` / `target="_blank"` popups.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
25pub enum AgentScope {
26    #[default]
27    Browser,
28    Page,
29}
30
31/// Best-effort in-page guardrail for page-scoped agents.
32///
33/// Idempotent: safe to evaluate repeatedly and to install as a preload
34/// script. Overrides `window.open` to navigate the current tab instead of
35/// opening a new one, rewrites `a[target="_blank"]` to `target="_self"`, and
36/// keeps re-applying the rewrite to dynamically added links via a
37/// `MutationObserver`.
38///
39/// NOTE: This is a best-effort guardrail, NOT a security boundary -- pages
40/// can still escape it (e.g. via iframes or captured native references). It
41/// only steers the agent away from multi-tab flows.
42///
43/// NOTE: This script is canonical across all language ports (TypeScript,
44/// Rust, Python, Go) -- do not change it in one port without updating the
45/// others.
46pub const GUARDRAIL_JS: &str = r#"(function () {
47  if (window.__spiderPageScopeGuard) return;
48  window.__spiderPageScopeGuard = true;
49  try {
50    window.open = function (url) {
51      if (url) { try { window.location.href = String(url); } catch (e) {} }
52      return null;
53    };
54  } catch (e) {}
55  var retarget = function () {
56    try {
57      var links = document.querySelectorAll('a[target="_blank"]');
58      for (var i = 0; i < links.length; i++) { links[i].target = '_self'; }
59    } catch (e) {}
60  };
61  retarget();
62  try {
63    new MutationObserver(retarget).observe(document, { childList: true, subtree: true });
64  } catch (e) {}
65})();"#;
66
67// -------------------------------------------------------------------
68// Action types (mirrors actions.rs AgentAction enum)
69// -------------------------------------------------------------------
70
71/// All possible agent actions.
72///
73/// Each variant is an externally-tagged enum so the JSON format is
74/// `{ "Click": "selector" }` or `{ "ClickPoint": { "x": 100, "y": 200 } }`.
75#[derive(Debug, Clone, Serialize, Deserialize)]
76pub enum AgentAction {
77    Click(String),
78    ClickAll(String),
79    ClickPoint { x: f64, y: f64 },
80    ClickHold { selector: String, hold_ms: u64 },
81    ClickHoldPoint { x: f64, y: f64, hold_ms: u64 },
82    DoubleClick(String),
83    DoubleClickPoint { x: f64, y: f64 },
84    RightClick(String),
85    RightClickPoint { x: f64, y: f64 },
86    WaitForAndClick(String),
87    ClickDrag {
88        from: String,
89        to: String,
90        #[serde(default)]
91        modifier: Option<u32>,
92    },
93    ClickDragPoint {
94        from_x: f64,
95        from_y: f64,
96        to_x: f64,
97        to_y: f64,
98        #[serde(default)]
99        modifier: Option<u32>,
100    },
101    Type { value: String },
102    Fill { selector: String, value: String },
103    Clear(String),
104    Press(String),
105    KeyDown(String),
106    KeyUp(String),
107    Select { selector: String, value: String },
108    Focus(String),
109    Blur(String),
110    Hover(String),
111    HoverPoint { x: f64, y: f64 },
112    ScrollY(f64),
113    ScrollX(f64),
114    ScrollTo { selector: String },
115    ScrollToPoint { x: f64, y: f64 },
116    InfiniteScroll(u32),
117    Wait(u64),
118    WaitFor(String),
119    WaitForWithTimeout { selector: String, timeout: u64 },
120    WaitForNavigation,
121    WaitForDom {
122        #[serde(default)]
123        selector: Option<String>,
124        timeout: u64,
125    },
126    Navigate(String),
127    GoBack,
128    GoForward,
129    Reload,
130    SetViewport {
131        width: u32,
132        height: u32,
133        #[serde(default)]
134        device_scale_factor: Option<f64>,
135        #[serde(default)]
136        mobile: Option<bool>,
137    },
138    Evaluate(String),
139    Screenshot,
140}
141
142/// Parsed LLM response -- mirrors `actions.rs AgentPlan`.
143#[derive(Debug, Clone, Serialize, Deserialize)]
144pub struct AgentPlan {
145    #[serde(default)]
146    pub label: String,
147    #[serde(default)]
148    pub done: bool,
149    #[serde(default)]
150    pub steps: Option<Vec<AgentAction>>,
151    #[serde(default)]
152    pub extracted: Option<Value>,
153    #[serde(default)]
154    pub memory_ops: Option<Vec<Value>>,
155}
156
157/// Options for the autonomous agent loop.
158#[derive(Debug, Clone)]
159pub struct AgentOptions {
160    /// Max automation rounds (default: 30).
161    pub max_rounds: u32,
162    /// Delay in ms after actions for page settle (default: 1500).
163    pub step_delay_ms: u64,
164    /// Extra context/instruction for each round.
165    pub instruction: Option<String>,
166    /// Scope of this agent run (default: [`AgentScope::Browser`]).
167    pub scope: AgentScope,
168    /// Per-run LLM override (used by `SpiderPage::agent()`; ignored by
169    /// `SpiderBrowser::agent()`, which is constructed with a provider
170    /// already resolved).
171    pub llm: Option<LLMConfig>,
172}
173
174impl Default for AgentOptions {
175    fn default() -> Self {
176        Self {
177            max_rounds: 30,
178            step_delay_ms: 1500,
179            instruction: None,
180            scope: AgentScope::default(),
181            llm: None,
182        }
183    }
184}
185
186/// Result of an agent execution run.
187#[derive(Debug, Clone, Serialize, Deserialize)]
188pub struct AgentResult {
189    /// Whether the agent completed the task.
190    pub done: bool,
191    /// Number of rounds executed.
192    pub rounds: u32,
193    /// Extracted data (accumulated across rounds).
194    #[serde(default, skip_serializing_if = "Option::is_none")]
195    pub extracted: Option<Value>,
196    /// Final label from the agent.
197    #[serde(default)]
198    pub label: String,
199}
200
201/// Autonomous multi-step agent.
202pub struct Agent<'a> {
203    adapter: &'a ProtocolAdapter,
204    llm: &'a dyn LLMProvider,
205    emitter: &'a SpiderEventEmitter,
206    max_rounds: u32,
207    step_delay_ms: u64,
208    scope: AgentScope,
209}
210
211impl<'a> Agent<'a> {
212    pub fn new(
213        adapter: &'a ProtocolAdapter,
214        llm: &'a dyn LLMProvider,
215        emitter: &'a SpiderEventEmitter,
216        options: Option<AgentOptions>,
217    ) -> Self {
218        let opts = options.unwrap_or_default();
219        Self {
220            adapter,
221            llm,
222            emitter,
223            max_rounds: opts.max_rounds,
224            step_delay_ms: opts.step_delay_ms,
225            scope: opts.scope,
226        }
227    }
228
229    /// Execute the agent loop until the task is done or max rounds reached.
230    pub async fn execute(&self, instruction: &str) -> AgentResult {
231        let mut extracted: Option<Value> = None;
232        let mut last_label = String::new();
233
234        // Small initial delay for page to render
235        sleep(Duration::from_millis(500)).await;
236
237        let system_prompt = build_system_prompt(self.scope);
238
239        // Page-scope guardrail: best-effort only, NOT a security boundary.
240        // Apply once to the current document, then try to install it as a
241        // persistent preload script (CDP Page.addScriptToEvaluateOnNewDocument)
242        // so it survives same-tab navigations. If persistent install fails
243        // (e.g. BiDi, which doesn't support it through our adapter), fall
244        // back to re-evaluating the idempotent snippet at the start of every
245        // round.
246        let mut guardrail_persistent = false;
247        if self.scope == AgentScope::Page {
248            let _ = self.adapter.evaluate(GUARDRAIL_JS).await;
249            guardrail_persistent = self
250                .adapter
251                .send_command(
252                    "Page.addScriptToEvaluateOnNewDocument",
253                    json!({ "source": GUARDRAIL_JS }),
254                )
255                .await
256                .is_ok();
257        }
258
259        for round in 0..self.max_rounds {
260            // Re-apply page-scope guardrail each round when no persistent
261            // preload script could be installed (idempotent, best-effort).
262            if self.scope == AgentScope::Page && !guardrail_persistent {
263                let _ = self.adapter.evaluate(GUARDRAIL_JS).await;
264            }
265            // 1. Capture screenshot
266            let screenshot = match self.adapter.capture_screenshot().await {
267                Ok(s) => s,
268                Err(err) => {
269                    warn!(round, error = %err, "agent: screenshot failed");
270                    break;
271                }
272            };
273
274            // 2. Get page HTML
275            let html = match self.adapter.get_html().await {
276                Ok(h) => h,
277                Err(err) => {
278                    warn!(round, error = %err, "agent: get HTML failed");
279                    break;
280                }
281            };
282
283            // 3. Get URL and title
284            let url = self
285                .adapter
286                .evaluate("window.location.href")
287                .await
288                .ok()
289                .and_then(|v| v.as_str().map(String::from))
290                .unwrap_or_else(|| "unknown".to_string());
291
292            let title = self
293                .adapter
294                .evaluate("document.title")
295                .await
296                .ok()
297                .and_then(|v| v.as_str().map(String::from))
298                .unwrap_or_default();
299
300            // 4. Call LLM
301            let context = format!(
302                "Round {}/{}. Task: {instruction}\nPAGE TITLE: {title}",
303                round + 1,
304                self.max_rounds
305            );
306
307            let messages = vec![
308                LLMMessage::system(system_prompt.as_ref()),
309                LLMMessage {
310                    role: LLMRole::User,
311                    content: LLMContent::Parts(build_user_message(
312                        &url,
313                        &html,
314                        &screenshot,
315                        Some(&context),
316                    )),
317                },
318            ];
319
320            let plan: AgentPlan = match crate::ai::llm_provider::chat_json(self.llm, &messages).await {
321                Ok(p) => p,
322                Err(err) => {
323                    warn!(round, error = %err, "agent: LLM call failed");
324                    sleep(Duration::from_millis(2000)).await;
325                    continue;
326                }
327            };
328
329            last_label = plan.label.clone();
330            if plan.extracted.is_some() {
331                extracted = plan.extracted.clone();
332            }
333
334            let steps_count = plan.steps.as_ref().map(|s| s.len()).unwrap_or(0);
335
336            info!(
337                round = round + 1,
338                label = %plan.label,
339                done = plan.done,
340                steps = steps_count,
341                "agent: round"
342            );
343
344            self.emitter.emit(
345                "agent.step",
346                json!({
347                    "round": round + 1,
348                    "label": plan.label,
349                    "stepsCount": steps_count,
350                }),
351            );
352
353            // 5. Check if done
354            if plan.done {
355                self.emitter.emit(
356                    "agent.done",
357                    json!({
358                        "rounds": round + 1,
359                        "result": extracted,
360                    }),
361                );
362                return AgentResult {
363                    done: true,
364                    rounds: round + 1,
365                    extracted,
366                    label: last_label,
367                };
368            }
369
370            if steps_count == 0 {
371                info!("agent: no steps, retrying");
372                sleep(Duration::from_millis(self.step_delay_ms)).await;
373                continue;
374            }
375
376            // 6. Execute each step
377            if let Some(ref steps) = plan.steps {
378                for (i, action) in steps.iter().enumerate() {
379                    if let Err(err) = execute_action(self.adapter, action).await {
380                        warn!(
381                            round,
382                            step = i,
383                            error = %err,
384                            "agent: action failed"
385                        );
386                        break;
387                    }
388                    sleep(Duration::from_millis(200)).await;
389                }
390            }
391
392            // 7. Wait for page to settle
393            sleep(Duration::from_millis(self.step_delay_ms)).await;
394        }
395
396        // Max rounds exceeded
397        warn!("agent: max rounds exceeded");
398        self.emitter.emit(
399            "agent.error",
400            json!({
401                "error": "max rounds exceeded",
402                "round": self.max_rounds,
403            }),
404        );
405
406        AgentResult {
407            done: false,
408            rounds: self.max_rounds,
409            extracted,
410            label: last_label,
411        }
412    }
413}
414
415// -------------------------------------------------------------------
416// Action executor -- mirrors agent.rs execute_action()
417// -------------------------------------------------------------------
418
419/// Execute a single agent action via the protocol adapter.
420///
421/// Handles all action types from the [`AgentAction`] enum.
422pub async fn execute_action(adapter: &ProtocolAdapter, action: &AgentAction) -> Result<()> {
423    match action {
424        // ----- Click actions -----
425        AgentAction::Click(selector) => {
426            let (x, y) = get_element_center(adapter, selector).await?;
427            adapter.click_point(x, y).await
428        }
429        AgentAction::ClickAll(selector) => {
430            let js = format!(
431                r#"(function() {{
432                    var els = document.querySelectorAll({sel});
433                    return Array.from(els).map(function(el) {{
434                        var r = el.getBoundingClientRect();
435                        return {{ x: r.x + r.width / 2, y: r.y + r.height / 2 }};
436                    }});
437                }})()"#,
438                sel = serde_json::to_string(selector).unwrap_or_default()
439            );
440            let val = adapter.evaluate(&js).await?;
441            if let Some(points) = val.as_array() {
442                for pt in points {
443                    let x = pt.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0);
444                    let y = pt.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0);
445                    adapter.click_point(x, y).await?;
446                    sleep(Duration::from_millis(100)).await;
447                }
448            }
449            Ok(())
450        }
451        AgentAction::ClickPoint { x, y } => adapter.click_point(*x, *y).await,
452        AgentAction::ClickHold { selector, hold_ms } => {
453            let (x, y) = get_element_center(adapter, selector).await?;
454            adapter.click_hold_point(x, y, *hold_ms).await
455        }
456        AgentAction::ClickHoldPoint { x, y, hold_ms } => {
457            adapter.click_hold_point(*x, *y, *hold_ms).await
458        }
459        AgentAction::DoubleClick(selector) => {
460            let (x, y) = get_element_center(adapter, selector).await?;
461            adapter.double_click_point(x, y).await
462        }
463        AgentAction::DoubleClickPoint { x, y } => adapter.double_click_point(*x, *y).await,
464        AgentAction::RightClick(selector) => {
465            let (x, y) = get_element_center(adapter, selector).await?;
466            adapter.right_click_point(x, y).await
467        }
468        AgentAction::RightClickPoint { x, y } => adapter.right_click_point(*x, *y).await,
469        AgentAction::WaitForAndClick(selector) => {
470            wait_for_element(adapter, selector, 5000).await?;
471            let (x, y) = get_element_center(adapter, selector).await?;
472            adapter.click_point(x, y).await
473        }
474
475        // ----- Drag actions -----
476        AgentAction::ClickDrag { from, to, .. } => {
477            let (fx, fy) = get_element_center(adapter, from).await?;
478            let (tx, ty) = get_element_center(adapter, to).await?;
479            adapter.drag_point(fx, fy, tx, ty).await
480        }
481        AgentAction::ClickDragPoint {
482            from_x,
483            from_y,
484            to_x,
485            to_y,
486            ..
487        } => adapter.drag_point(*from_x, *from_y, *to_x, *to_y).await,
488
489        // ----- Input actions -----
490        AgentAction::Type { value } => adapter.insert_text(value).await,
491        AgentAction::Fill { selector, value } => {
492            // Clear via JS
493            let sel_json = serde_json::to_string(selector).unwrap_or_default();
494            let clear_js = format!(
495                r#"(function() {{
496                    var el = document.querySelector({sel_json});
497                    if (el) {{ el.focus(); el.value = ''; }}
498                }})()"#
499            );
500            adapter.evaluate(&clear_js).await?;
501
502            // Click for real focus
503            if let Ok((x, y)) = get_element_center(adapter, selector).await {
504                let _ = adapter.click_point(x, y).await;
505            }
506
507            // Insert text
508            adapter.insert_text(value).await?;
509
510            // Dispatch events
511            let event_js = format!(
512                r#"(function() {{
513                    var el = document.querySelector({sel_json});
514                    if (el) {{
515                        el.dispatchEvent(new Event('input', {{ bubbles: true }}));
516                        el.dispatchEvent(new Event('change', {{ bubbles: true }}));
517                    }}
518                }})()"#
519            );
520            adapter.evaluate(&event_js).await?;
521            Ok(())
522        }
523        AgentAction::Clear(selector) => {
524            let sel_json = serde_json::to_string(selector).unwrap_or_default();
525            let js = format!("document.querySelector({sel_json}).value = ''");
526            adapter.evaluate(&js).await?;
527            Ok(())
528        }
529        AgentAction::Press(key) => adapter.press_key(key).await,
530        AgentAction::KeyDown(key) => adapter.key_down(key).await,
531        AgentAction::KeyUp(key) => adapter.key_up(key).await,
532
533        // ----- Select & Focus -----
534        AgentAction::Select { selector, value } => {
535            let sel_json = serde_json::to_string(selector).unwrap_or_default();
536            let val_json = serde_json::to_string(value).unwrap_or_default();
537            let js = format!(
538                r#"(function() {{
539                    var el = document.querySelector({sel_json});
540                    if (el) {{
541                        el.value = {val_json};
542                        el.dispatchEvent(new Event('change', {{ bubbles: true }}));
543                    }}
544                }})()"#
545            );
546            adapter.evaluate(&js).await?;
547            Ok(())
548        }
549        AgentAction::Focus(selector) => {
550            let sel_json = serde_json::to_string(selector).unwrap_or_default();
551            adapter
552                .evaluate(&format!(
553                    "document.querySelector({sel_json})?.focus()"
554                ))
555                .await?;
556            Ok(())
557        }
558        AgentAction::Blur(selector) => {
559            let sel_json = serde_json::to_string(selector).unwrap_or_default();
560            adapter
561                .evaluate(&format!(
562                    "document.querySelector({sel_json})?.blur()"
563                ))
564                .await?;
565            Ok(())
566        }
567        AgentAction::Hover(selector) => {
568            let (x, y) = get_element_center(adapter, selector).await?;
569            adapter.hover_point(x, y).await
570        }
571        AgentAction::HoverPoint { x, y } => adapter.hover_point(*x, *y).await,
572
573        // ----- Scroll actions -----
574        AgentAction::ScrollY(delta) => {
575            adapter
576                .evaluate(&format!("window.scrollBy(0, {delta})"))
577                .await?;
578            Ok(())
579        }
580        AgentAction::ScrollX(delta) => {
581            adapter
582                .evaluate(&format!("window.scrollBy({delta}, 0)"))
583                .await?;
584            Ok(())
585        }
586        AgentAction::ScrollTo { selector } => {
587            let sel_json = serde_json::to_string(selector).unwrap_or_default();
588            adapter
589                .evaluate(&format!(
590                    "document.querySelector({sel_json})?.scrollIntoView({{ behavior: 'smooth', block: 'center' }})"
591                ))
592                .await?;
593            Ok(())
594        }
595        AgentAction::ScrollToPoint { x, y } => {
596            adapter
597                .evaluate(&format!("window.scrollTo({x}, {y})"))
598                .await?;
599            Ok(())
600        }
601        AgentAction::InfiniteScroll(max) => {
602            for _ in 0..*max {
603                adapter
604                    .evaluate("window.scrollTo(0, document.body.scrollHeight)")
605                    .await?;
606                sleep(Duration::from_millis(500)).await;
607            }
608            Ok(())
609        }
610
611        // ----- Wait actions -----
612        AgentAction::Wait(ms) => {
613            sleep(Duration::from_millis(*ms)).await;
614            Ok(())
615        }
616        AgentAction::WaitFor(selector) => wait_for_element(adapter, selector, 5000).await,
617        AgentAction::WaitForWithTimeout { selector, timeout } => {
618            wait_for_element(adapter, selector, *timeout).await
619        }
620        AgentAction::WaitForNavigation => {
621            sleep(Duration::from_millis(1000)).await;
622            Ok(())
623        }
624        AgentAction::WaitForDom { timeout, .. } => {
625            sleep(Duration::from_millis(*timeout)).await;
626            Ok(())
627        }
628
629        // ----- Navigation actions -----
630        AgentAction::Navigate(url) => adapter.navigate(url).await,
631        AgentAction::GoBack => {
632            adapter.evaluate("window.history.back()").await?;
633            Ok(())
634        }
635        AgentAction::GoForward => {
636            adapter.evaluate("window.history.forward()").await?;
637            Ok(())
638        }
639        AgentAction::Reload => {
640            adapter.evaluate("window.location.reload()").await?;
641            Ok(())
642        }
643
644        // ----- Viewport -----
645        AgentAction::SetViewport {
646            width,
647            height,
648            device_scale_factor,
649            mobile,
650        } => {
651            adapter
652                .set_viewport(
653                    *width,
654                    *height,
655                    device_scale_factor.unwrap_or(2.0),
656                    mobile.unwrap_or(false),
657                )
658                .await
659        }
660
661        // ----- JavaScript -----
662        AgentAction::Evaluate(code) => {
663            adapter.evaluate(code).await?;
664            Ok(())
665        }
666
667        // ----- Screenshot (no-op in client -- handled by agent loop) -----
668        AgentAction::Screenshot => Ok(()),
669    }
670}
671
672// -------------------------------------------------------------------
673// Helpers (mirrors agent.rs get_element_center / wait_for_element)
674// -------------------------------------------------------------------
675
676/// Get the center point of a DOM element, scrolling it into view first.
677async fn get_element_center(adapter: &ProtocolAdapter, selector: &str) -> Result<(f64, f64)> {
678    let sel_json = serde_json::to_string(selector).unwrap_or_default();
679    let js = format!(
680        r#"(function() {{
681            var el = document.querySelector({sel_json});
682            if (!el) return null;
683            el.scrollIntoView({{ block: 'center', behavior: 'instant' }});
684            var r = el.getBoundingClientRect();
685            return {{ x: r.x + r.width / 2, y: r.y + r.height / 2 }};
686        }})()"#
687    );
688
689    let result = adapter.evaluate(&js).await?;
690    if result.is_null() {
691        return Err(SpiderError::Other(format!(
692            "Element not found: {selector}"
693        )));
694    }
695
696    let x = result
697        .get("x")
698        .and_then(|v| v.as_f64())
699        .ok_or_else(|| SpiderError::Other(format!("Missing x for element: {selector}")))?;
700    let y = result
701        .get("y")
702        .and_then(|v| v.as_f64())
703        .ok_or_else(|| SpiderError::Other(format!("Missing y for element: {selector}")))?;
704
705    Ok((x, y))
706}
707
708/// Poll for a DOM element until it appears or timeout expires.
709async fn wait_for_element(adapter: &ProtocolAdapter, selector: &str, timeout_ms: u64) -> Result<()> {
710    let interval = 100u64;
711    let max_iter = (timeout_ms + interval - 1) / interval;
712    let sel_json = serde_json::to_string(selector).unwrap_or_default();
713    let check_js = format!("!!document.querySelector({sel_json})");
714
715    for _ in 0..max_iter {
716        let found = adapter.evaluate(&check_js).await?;
717        if found.as_bool().unwrap_or(false) {
718            return Ok(());
719        }
720        sleep(Duration::from_millis(interval)).await;
721    }
722
723    Err(SpiderError::Timeout(format!(
724        "Timeout waiting for element: {selector}"
725    )))
726}
727
728#[cfg(test)]
729mod tests {
730    use super::*;
731
732    #[test]
733    fn agent_scope_default_is_browser() {
734        assert_eq!(AgentScope::default(), AgentScope::Browser);
735    }
736
737    #[test]
738    fn agent_options_default_scope_is_browser() {
739        assert_eq!(AgentOptions::default().scope, AgentScope::Browser);
740    }
741
742    #[test]
743    fn guardrail_js_is_idempotent_guarded() {
744        assert!(GUARDRAIL_JS.contains("__spiderPageScopeGuard"));
745        assert!(GUARDRAIL_JS.contains("window.open"));
746        assert!(GUARDRAIL_JS.contains("target=\"_blank\""));
747    }
748}