1use 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 #[serde(default)]
32 pub novnc_url: Option<String>,
33 #[serde(default)]
36 pub vnc_url: Option<String>,
37 #[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 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 #[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 #[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>) {}