Skip to main content

voidcrawl_mcp/tools/
challenge.rs

1//! Challenge escalation tools.
2//!
3//! These tools own the manual/VNC loop for V1: capture the current wall,
4//! expose same-tab attach coordinates, let an operator clear it through VNC or
5//! noVNC, then mark the event resolved/failed. Later resolvers (Yosoi,
6//! OpenSesame, agents) use the same event and attach contract.
7
8use std::{env, time::Duration};
9
10use rmcp::ErrorData;
11use schemars::JsonSchema;
12use serde::{Deserialize, Serialize};
13use tokio::time::{Instant, sleep};
14use uuid::Uuid;
15use void_crawl_core::{
16    AntibotVerdict, AttachCoordinates, ChallengeSnapshot, ChallengeStatus, DomCaptchaSnapshot,
17    ResolutionOutcome, ResolverType, capture_captcha,
18};
19
20use crate::{
21    errors::map_err,
22    server::VoidCrawlServer,
23    sessions::{LastNavigation, PendingChallenge},
24    tools::fetch::AntibotInfo,
25};
26
27#[derive(Debug, Deserialize, JsonSchema, Default)]
28pub struct CaptureChallengeArgs {
29    pub session_id: String,
30    /// Optional noVNC URL for the human operator, e.g. http://127.0.0.1:6080.
31    #[serde(default)]
32    pub novnc_url:  Option<String>,
33    /// Optional native VNC URL for lower-latency clients, e.g.
34    /// vnc://127.0.0.1:5900.
35    #[serde(default)]
36    pub vnc_url:    Option<String>,
37    /// Include a compact AX tree summary inline for triage. On by default.
38    #[serde(default = "default_include_ax")]
39    pub include_ax: bool,
40}
41
42fn default_include_ax() -> bool {
43    true
44}
45
46#[derive(Debug, Serialize, JsonSchema)]
47pub struct ChallengeAttachInfo {
48    pub websocket_url: Option<String>,
49    pub target_id:     Option<String>,
50    pub session_id:    Option<String>,
51    pub vnc_url:       Option<String>,
52    pub novnc_url:     Option<String>,
53}
54
55impl From<AttachCoordinates> for ChallengeAttachInfo {
56    fn from(coords: AttachCoordinates) -> Self {
57        Self {
58            websocket_url: coords.websocket_url,
59            target_id:     coords.target_id,
60            session_id:    coords.session_id,
61            vnc_url:       coords.vnc_url,
62            novnc_url:     coords.novnc_url,
63        }
64    }
65}
66
67#[derive(Debug, Serialize, JsonSchema)]
68pub struct DomCaptchaInfo {
69    pub kind:                    String,
70    pub sitekey:                 Option<String>,
71    pub widget_selector:         Option<String>,
72    pub widget_rect:             Option<WidgetRectInfo>,
73    pub widget_rendered:         bool,
74    pub response_field_selector: Option<String>,
75    pub existing_token_present:  bool,
76    pub action:                  Option<String>,
77    pub cdata:                   Option<String>,
78    pub page_url:                String,
79    pub active:                  bool,
80}
81
82#[derive(Debug, Serialize, JsonSchema)]
83pub struct WidgetRectInfo {
84    pub x:      f64,
85    pub y:      f64,
86    pub width:  f64,
87    pub height: f64,
88}
89
90impl From<DomCaptchaSnapshot> for DomCaptchaInfo {
91    fn from(c: DomCaptchaSnapshot) -> Self {
92        Self {
93            kind:                    c.kind,
94            sitekey:                 c.sitekey,
95            widget_selector:         c.widget_selector,
96            widget_rect:             c.widget_rect.map(|r| WidgetRectInfo {
97                x:      r.x,
98                y:      r.y,
99                width:  r.width,
100                height: r.height,
101            }),
102            widget_rendered:         c.widget_rendered,
103            response_field_selector: c.response_field_selector,
104            existing_token_present:  c.existing_token_present,
105            action:                  c.action,
106            cdata:                   c.cdata,
107            page_url:                c.page_url,
108            active:                  c.active,
109        }
110    }
111}
112
113#[derive(Debug, Serialize, JsonSchema)]
114pub struct ChallengeSnapshotInfo {
115    pub event_id: String,
116    pub url: String,
117    pub status_code: Option<u16>,
118    pub status: String,
119    pub blocking: bool,
120    pub antibot: Option<AntibotInfo>,
121    pub dom_captcha: Option<DomCaptchaInfo>,
122    pub evidence_corpus_versions: Vec<String>,
123    pub screenshot_handle: Option<String>,
124    pub ax_summary_handle: Option<String>,
125    pub ax_summary: Option<String>,
126    pub attach_coordinates: ChallengeAttachInfo,
127}
128
129impl ChallengeSnapshotInfo {
130    fn from_snapshot(snapshot: ChallengeSnapshot, ax_summary: Option<String>) -> Self {
131        let blocking = snapshot.is_blocking();
132        let status = status_tag(snapshot.status).to_string();
133        Self {
134            event_id: snapshot.event_id,
135            url: snapshot.url,
136            status_code: snapshot.status_code,
137            status,
138            blocking,
139            antibot: snapshot.antibot.map(AntibotInfo::from),
140            dom_captcha: snapshot.dom_captcha.map(DomCaptchaInfo::from),
141            evidence_corpus_versions: snapshot.evidence_corpus_versions,
142            screenshot_handle: snapshot.screenshot_handle,
143            ax_summary_handle: snapshot.ax_summary_handle,
144            ax_summary,
145            attach_coordinates: snapshot.attach_coordinates.into(),
146        }
147    }
148}
149
150#[derive(Debug, Serialize, JsonSchema)]
151pub struct CaptureChallengeResult {
152    pub challenge:     ChallengeSnapshotInfo,
153    /// Human-readable next action for V1 manual resolution.
154    pub operator_hint: String,
155}
156
157#[derive(Debug, Deserialize, JsonSchema, Default)]
158pub struct MarkChallengeArgs {
159    pub session_id: String,
160    pub event_id:   String,
161    /// One of manual_vnc, yosoi_recipe, open_sesame_session_actor, agent_mcp,
162    /// rotate_identity, fail. Defaults to manual_vnc for resolved and fail for
163    /// failed.
164    #[serde(default)]
165    pub resolver:   Option<String>,
166    #[serde(default)]
167    pub elapsed_ms: Option<u64>,
168    #[serde(default)]
169    pub note:       Option<String>,
170}
171
172#[derive(Debug, Deserialize, JsonSchema, Default)]
173pub struct WaitChallengeArgs {
174    pub session_id:    String,
175    pub event_id:      String,
176    #[serde(default)]
177    pub timeout_secs:  Option<u64>,
178    /// Re-probe DOM after a resolved mark. On by default so callers can resume
179    /// only when the visible wall is actually gone.
180    #[serde(default = "default_reprobe")]
181    pub reprobe_after: bool,
182}
183
184fn default_reprobe() -> bool {
185    true
186}
187
188#[derive(Debug, Serialize, JsonSchema)]
189pub struct ResolutionOutcomeInfo {
190    pub event_id:   String,
191    pub resolver:   String,
192    pub status:     String,
193    pub elapsed_ms: Option<u64>,
194    pub note:       Option<String>,
195}
196
197impl From<ResolutionOutcome> for ResolutionOutcomeInfo {
198    fn from(outcome: ResolutionOutcome) -> Self {
199        Self {
200            event_id:   outcome.event_id,
201            resolver:   resolver_tag(outcome.resolver).to_string(),
202            status:     status_tag(outcome.status).to_string(),
203            elapsed_ms: outcome.elapsed_ms,
204            note:       outcome.note,
205        }
206    }
207}
208
209#[derive(Debug, Serialize, JsonSchema)]
210pub struct ResolutionResult {
211    pub outcome: ResolutionOutcomeInfo,
212}
213
214#[derive(Debug, Serialize, JsonSchema)]
215pub struct WaitChallengeResult {
216    pub resolved:              bool,
217    pub outcome:               Option<ResolutionOutcomeInfo>,
218    pub captcha_still_present: Option<bool>,
219}
220
221pub async fn capture(
222    server: &VoidCrawlServer,
223    args: CaptureChallengeArgs,
224) -> Result<CaptureChallengeResult, ErrorData> {
225    let handle = server.state().sessions.get(&args.session_id).await.ok_or_else(|| {
226        ErrorData::invalid_params(format!("unknown session_id: {}", args.session_id), None)
227    })?;
228    let page = handle.page.lock().await;
229    let last = handle.last_navigation.lock().await.clone();
230    let url = page
231        .url()
232        .await
233        .map_err(map_err)?
234        .or_else(|| last.as_ref().map(|nav| nav.url.clone()))
235        .unwrap_or_default();
236    let status_code = last.as_ref().and_then(|nav| nav.status_code);
237    let antibot = last.and_then(|nav| nav.antibot).filter(AntibotVerdict::detected);
238    let dom_captcha = capture_captcha(&page).await.map_err(map_err)?.map(DomCaptchaSnapshot::from);
239    let ax_summary = if args.include_ax { page.ax_tree_outline(Some(3)).await.ok() } else { None };
240    let websocket_url = handle.session.websocket_url().await;
241    let target_id = page.target_id();
242    let attach_coordinates = AttachCoordinates {
243        websocket_url: Some(websocket_url),
244        target_id:     Some(target_id),
245        session_id:    Some(args.session_id.clone()),
246        vnc_url:       args.vnc_url.or_else(|| env::var("VOIDCRAWL_VNC_URL").ok()),
247        novnc_url:     args.novnc_url.or_else(|| env::var("VOIDCRAWL_NOVNC_URL").ok()),
248    };
249    let mut versions = Vec::new();
250    if let Some(v) = antibot.as_ref() {
251        versions.push(format!("antibot:{}", v.corpus_version));
252    }
253    if dom_captcha.is_some() {
254        versions.push("dom_captcha:runtime".to_string());
255    }
256    let snapshot = ChallengeSnapshot {
257        event_id: Uuid::new_v4().to_string(),
258        url,
259        status_code,
260        status: ChallengeStatus::Active,
261        antibot,
262        dom_captcha,
263        evidence_corpus_versions: versions,
264        screenshot_handle: None,
265        ax_summary_handle: ax_summary.as_ref().map(|_| "inline:ax_summary".to_string()),
266        attach_coordinates,
267    };
268    let blocking = snapshot.is_blocking();
269    if blocking {
270        *handle.challenge.lock().await =
271            Some(PendingChallenge { snapshot: snapshot.clone(), outcome: None });
272    }
273    let operator_hint = if blocking {
274        "Open novnc_url or vnc_url, clear the wall in that browser, then call mark_challenge_resolved and wait_for_challenge_resolution.".to_string()
275    } else {
276        "No active challenge was detected. Presence-only CDN/anti-bot signals are telemetry and do not require a manual pause.".to_string()
277    };
278    Ok(CaptureChallengeResult {
279        challenge: ChallengeSnapshotInfo::from_snapshot(snapshot, ax_summary),
280        operator_hint,
281    })
282}
283
284pub async fn mark_resolved(
285    server: &VoidCrawlServer,
286    args: MarkChallengeArgs,
287) -> Result<ResolutionResult, ErrorData> {
288    mark(server, args, ChallengeStatus::Resolved).await
289}
290
291pub async fn mark_failed(
292    server: &VoidCrawlServer,
293    args: MarkChallengeArgs,
294) -> Result<ResolutionResult, ErrorData> {
295    mark(server, args, ChallengeStatus::Failed).await
296}
297
298async fn mark(
299    server: &VoidCrawlServer,
300    args: MarkChallengeArgs,
301    status: ChallengeStatus,
302) -> Result<ResolutionResult, ErrorData> {
303    let handle = server.state().sessions.get(&args.session_id).await.ok_or_else(|| {
304        ErrorData::invalid_params(format!("unknown session_id: {}", args.session_id), None)
305    })?;
306    let mut guard = handle.challenge.lock().await;
307    let Some(pending) = guard.as_mut() else {
308        return Err(ErrorData::invalid_params("no active challenge for session", None));
309    };
310    if pending.snapshot.event_id != args.event_id {
311        return Err(ErrorData::invalid_params(
312            format!("event_id mismatch: active event is {}", pending.snapshot.event_id),
313            None,
314        ));
315    }
316    pending.snapshot.status = status;
317    let default_resolver = if status == ChallengeStatus::Resolved {
318        ResolverType::ManualVnc
319    } else {
320        ResolverType::Fail
321    };
322    let resolver =
323        args.resolver.as_deref().map(parse_resolver).transpose()?.unwrap_or(default_resolver);
324    let outcome = ResolutionOutcome {
325        event_id: args.event_id,
326        resolver,
327        status,
328        elapsed_ms: args.elapsed_ms,
329        note: args.note,
330    };
331    pending.outcome = Some(outcome.clone());
332    Ok(ResolutionResult { outcome: outcome.into() })
333}
334
335pub async fn wait_for_resolution(
336    server: &VoidCrawlServer,
337    args: WaitChallengeArgs,
338) -> Result<WaitChallengeResult, ErrorData> {
339    let handle = server.state().sessions.get(&args.session_id).await.ok_or_else(|| {
340        ErrorData::invalid_params(format!("unknown session_id: {}", args.session_id), None)
341    })?;
342    let timeout = Duration::from_secs(args.timeout_secs.unwrap_or(300));
343    let deadline = Instant::now() + timeout;
344    loop {
345        let outcome = {
346            let guard = handle.challenge.lock().await;
347            let Some(pending) = guard.as_ref() else {
348                return Err(ErrorData::invalid_params("no active challenge for session", None));
349            };
350            if pending.snapshot.event_id != args.event_id {
351                return Err(ErrorData::invalid_params(
352                    format!("event_id mismatch: active event is {}", pending.snapshot.event_id),
353                    None,
354                ));
355            }
356            pending.outcome.clone()
357        };
358        if let Some(outcome) = outcome {
359            let captcha_still_present =
360                if outcome.status == ChallengeStatus::Resolved && args.reprobe_after {
361                    let page = handle.page.lock().await;
362                    let info = capture_captcha(&page).await.map_err(map_err)?;
363                    info.as_ref().map(void_crawl_core::captcha_is_active)
364                } else {
365                    None
366                };
367            return Ok(WaitChallengeResult {
368                resolved: outcome.status == ChallengeStatus::Resolved,
369                outcome: Some(outcome.into()),
370                captcha_still_present,
371            });
372        }
373        if Instant::now() >= deadline {
374            return Ok(WaitChallengeResult {
375                resolved:              false,
376                outcome:               None,
377                captcha_still_present: None,
378            });
379        }
380        sleep(Duration::from_millis(250)).await;
381    }
382}
383
384fn parse_resolver(value: &str) -> Result<ResolverType, ErrorData> {
385    match value {
386        "manual_vnc" => Ok(ResolverType::ManualVnc),
387        "yosoi_recipe" => Ok(ResolverType::YosoiRecipe),
388        "open_sesame_session_actor" => Ok(ResolverType::OpenSesameSessionActor),
389        "agent_mcp" => Ok(ResolverType::AgentMcp),
390        "rotate_identity" => Ok(ResolverType::RotateIdentity),
391        "fail" => Ok(ResolverType::Fail),
392        other => Err(ErrorData::invalid_params(format!("unknown resolver: {other}"), None)),
393    }
394}
395
396fn resolver_tag(resolver: ResolverType) -> &'static str {
397    match resolver {
398        ResolverType::ManualVnc => "manual_vnc",
399        ResolverType::YosoiRecipe => "yosoi_recipe",
400        ResolverType::OpenSesameSessionActor => "open_sesame_session_actor",
401        ResolverType::AgentMcp => "agent_mcp",
402        ResolverType::RotateIdentity => "rotate_identity",
403        ResolverType::Fail => "fail",
404    }
405}
406
407fn status_tag(status: ChallengeStatus) -> &'static str {
408    match status {
409        ChallengeStatus::Active => "active",
410        ChallengeStatus::Resolved => "resolved",
411        ChallengeStatus::Failed => "failed",
412    }
413}
414
415#[allow(dead_code, reason = "keeps import visible for rustdoc intra-module links")]
416fn _last_navigation_type_anchor(_: Option<LastNavigation>) {}