1use std::path::PathBuf;
4
5use serde::{Deserialize, Serialize};
6
7use crate::shared::artifacts::Artifact;
8use crate::shared::error::{Error, ErrorCode};
9use crate::shared::ids::{RequestId, TabId};
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct FetchResult {
15 pub request_id: RequestId,
16 pub request_url: String,
17 pub final_url: String,
18 pub status: u16,
19 #[serde(skip_serializing_if = "Option::is_none")]
20 pub page_kind: Option<PageKind>,
21 #[serde(skip_serializing_if = "Option::is_none")]
22 pub next_action: Option<NextAction>,
23 #[serde(skip_serializing_if = "Option::is_none")]
24 pub tab_id: Option<TabId>,
25 pub trace: Trace,
26 #[serde(skip_serializing_if = "Vec::is_empty", default)]
27 pub warnings: Vec<Warning>,
28 #[serde(skip_serializing_if = "Option::is_none")]
29 pub body_file: Option<PathBuf>,
30 #[serde(skip_serializing_if = "Option::is_none")]
31 pub rendered_html_file: Option<PathBuf>,
32 #[serde(skip_serializing_if = "Option::is_none")]
33 pub text_file: Option<PathBuf>,
34 #[serde(skip_serializing_if = "Option::is_none")]
35 pub content_file: Option<PathBuf>,
36 #[serde(skip_serializing_if = "Option::is_none")]
37 pub content_json_file: Option<PathBuf>,
38 #[serde(skip_serializing_if = "Option::is_none")]
39 pub screenshot_file: Option<PathBuf>,
40 #[serde(skip_serializing_if = "Option::is_none")]
41 pub network_file: Option<PathBuf>,
42 #[serde(skip_serializing_if = "Option::is_none")]
43 pub console_file: Option<PathBuf>,
44 #[serde(skip_serializing_if = "Option::is_none")]
45 pub observation_file: Option<PathBuf>,
46 #[serde(skip_serializing_if = "Option::is_none")]
47 pub storage_file: Option<PathBuf>,
48 #[serde(skip_serializing_if = "Option::is_none")]
49 pub download_file: Option<PathBuf>,
50 #[serde(skip_serializing_if = "Option::is_none")]
51 pub download_bytes: Option<u64>,
52 #[serde(skip_serializing_if = "Option::is_none")]
53 pub download_filename: Option<String>,
54 #[serde(skip_serializing_if = "Option::is_none")]
55 pub download_url: Option<String>,
56 #[serde(skip_serializing_if = "Option::is_none")]
57 pub download_state: Option<String>,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize, Default)]
61pub struct Trace {
62 pub render_decision: RenderDecision,
63 #[serde(default)]
69 pub render_mode: TraceRenderMode,
70 #[serde(default)]
74 pub render_used: bool,
75 #[serde(skip_serializing_if = "Option::is_none")]
76 pub escalation_reason: Option<String>,
77 pub main_request_observed: bool,
78 pub duration_ms: u64,
79 pub timeout_ms: u64,
80 pub current_stage: String,
81 #[serde(skip_serializing_if = "Option::is_none")]
82 pub navigation_duration_ms: Option<u64>,
83 #[serde(skip_serializing_if = "Option::is_none")]
86 pub wait_mode: Option<String>,
87 #[serde(skip_serializing_if = "Option::is_none")]
89 pub wait_satisfied_by: Option<String>,
90 #[serde(skip_serializing_if = "Option::is_none")]
94 pub network_quiet: Option<bool>,
95 #[serde(skip_serializing_if = "Option::is_none")]
96 pub dom_stable: Option<bool>,
97 #[serde(skip_serializing_if = "Option::is_none")]
98 pub text_stable: Option<bool>,
99 #[serde(skip_serializing_if = "Option::is_none")]
101 pub capture_reason: Option<String>,
102 #[serde(skip_serializing_if = "Option::is_none")]
107 pub cookie_jar_file: Option<std::path::PathBuf>,
108 #[serde(skip_serializing_if = "Option::is_none")]
111 pub cookie_jar_warning: Option<String>,
112 #[serde(skip_serializing_if = "Vec::is_empty", default)]
115 pub sensitive_capture: Vec<String>,
116 pub stages: Vec<TraceStage>,
117}
118
119#[derive(Debug, Clone, Serialize, Deserialize)]
120pub struct TraceStage {
121 pub name: String,
122 pub status: TraceStageStatus,
123 pub duration_ms: u64,
124}
125
126#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
127#[serde(rename_all = "snake_case")]
128pub enum TraceStageStatus {
129 Ok,
130 Error,
131 Timeout,
132 Started,
133}
134
135#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
139#[serde(rename_all = "snake_case")]
140pub enum TraceRenderMode {
141 None,
142 #[default]
143 Auto,
144 Always,
145}
146
147#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
148#[serde(rename_all = "snake_case")]
149pub enum RenderDecision {
150 #[default]
151 HttpOnly,
152 Browser,
153}
154
155#[derive(Debug, Clone, Serialize, Deserialize)]
156pub struct Warning {
157 pub artifact: Artifact,
158 pub code: crate::shared::error::ErrorCode,
159 pub detail: String,
160}
161
162#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
165#[serde(rename_all = "snake_case")]
166pub enum PageKind {
167 BotWallDetected,
168 SecurityChallengeDetected,
169}
170
171#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
172pub struct NextAction {
173 pub kind: NextActionKind,
174 pub recommended_command: String,
175 pub target_content_verified: bool,
176 #[serde(skip_serializing_if = "Option::is_none")]
180 #[serde(alias = "takeover_url")]
181 pub takeover_url_secret: Option<String>,
182 #[serde(skip_serializing_if = "Option::is_none")]
183 pub takeover_url_expires_at_rfc3339: Option<String>,
184 #[serde(skip_serializing_if = "Option::is_none")]
185 pub takeover_url_ttl_s: Option<u64>,
186 #[serde(skip_serializing_if = "Option::is_none")]
187 pub takeover_url_scope: Option<String>,
188}
189
190#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
191#[serde(rename_all = "snake_case")]
192pub enum NextActionKind {
193 HumanTakeover,
194}
195
196impl NextAction {
197 fn human_takeover(url: &str) -> Self {
198 Self {
199 kind: NextActionKind::HumanTakeover,
200 recommended_command: format!("afhttp fetch {} --takeover", shell_quote(url)),
201 target_content_verified: false,
202 takeover_url_secret: None,
203 takeover_url_expires_at_rfc3339: None,
204 takeover_url_ttl_s: None,
205 takeover_url_scope: None,
206 }
207 }
208}
209
210#[derive(Debug, Clone, Serialize, Deserialize)]
213pub struct FetchError {
214 pub error_code: ErrorCode,
215 #[serde(rename = "error")]
216 pub detail: String,
217 pub retryable: bool,
218 pub trace: Trace,
219}
220
221impl FetchError {
222 #[must_use]
223 pub fn new(error: Error, trace: Trace) -> Self {
224 Self {
225 error_code: error.error_code,
226 detail: error.detail,
227 retryable: error.retryable,
228 trace,
229 }
230 }
231
232 #[must_use]
233 pub fn into_error(self) -> Error {
234 Error {
235 error_code: self.error_code,
236 detail: self.detail,
237 retryable: self.retryable,
238 }
239 }
240
241 #[must_use]
242 pub fn as_error(&self) -> Error {
243 Error {
244 error_code: self.error_code,
245 detail: self.detail.clone(),
246 retryable: self.retryable,
247 }
248 }
249}
250
251pub struct EscalationReason;
262
263impl EscalationReason {
264 pub const EMPTY_HTML_SHELL: &'static str = "empty_html_shell";
266
267 #[must_use]
269 pub fn http_status(status: u16) -> String {
270 format!("http_status_{status}")
271 }
272
273 #[must_use]
275 pub fn http_failed(error_code: &str) -> String {
276 format!("http_failed_{error_code}")
277 }
278}
279
280impl FetchResult {
281 pub(crate) fn new(request_id: RequestId, url: String, trace: Trace) -> Self {
286 Self {
287 request_id,
288 final_url: url.clone(),
289 request_url: url,
290 status: 0,
291 page_kind: None,
292 next_action: None,
293 tab_id: None,
294 trace,
295 warnings: Vec::new(),
296 body_file: None,
297 rendered_html_file: None,
298 text_file: None,
299 content_file: None,
300 content_json_file: None,
301 screenshot_file: None,
302 network_file: None,
303 console_file: None,
304 observation_file: None,
305 storage_file: None,
306 download_file: None,
307 download_bytes: None,
308 download_filename: None,
309 download_url: None,
310 download_state: None,
311 }
312 }
313
314 pub fn set_artifact_file(&mut self, artifact: Artifact, path: PathBuf) {
317 match artifact {
318 Artifact::Body => self.body_file = Some(path),
319 Artifact::RenderedHtml => self.rendered_html_file = Some(path),
320 Artifact::Text => self.text_file = Some(path),
321 Artifact::Content => self.content_file = Some(path),
322 Artifact::ContentJson => self.content_json_file = Some(path),
323 Artifact::Screenshot => self.screenshot_file = Some(path),
324 Artifact::Network => self.network_file = Some(path),
325 Artifact::Console => self.console_file = Some(path),
326 Artifact::Observation => self.observation_file = Some(path),
327 Artifact::Storage => self.storage_file = Some(path),
328 }
329 }
330
331 pub(crate) fn set_page_kind(&mut self, kind: PageKind) {
332 self.page_kind = Some(kind);
333 if matches!(
334 kind,
335 PageKind::BotWallDetected | PageKind::SecurityChallengeDetected
336 ) {
337 self.next_action = Some(NextAction::human_takeover(&self.request_url));
338 }
339 }
340
341 pub fn attach_takeover(&mut self, takeover_url_secret: String) {
346 self.attach_takeover_with_context(takeover_url_secret, None, None, None, None, None);
347 }
348
349 pub fn attach_takeover_with_context(
352 &mut self,
353 takeover_url_secret: String,
354 expires_at_rfc3339: Option<String>,
355 ttl_s: Option<u64>,
356 scope: Option<String>,
357 endpoint: Option<&str>,
358 profile: Option<&str>,
359 ) {
360 let url = self.request_url.clone();
361 let tab = self.tab_id.as_ref().map(|t| t.as_str().to_string());
362 if let Some(next) = self.next_action.as_mut() {
363 next.takeover_url_secret = Some(takeover_url_secret);
364 next.takeover_url_expires_at_rfc3339 = expires_at_rfc3339;
365 next.takeover_url_ttl_s = ttl_s;
366 next.takeover_url_scope = scope;
367 if let Some(tab) = tab {
368 let mut command = format!(
369 "afhttp fetch {} --takeover --tab {}",
370 shell_quote(&url),
371 shell_quote(&tab),
372 );
373 if let Some(endpoint) = endpoint {
374 command.push_str(" --endpoint-url ");
375 command.push_str(&shell_quote(endpoint));
376 }
377 if let Some(profile) = profile {
378 command.push_str(" --profile ");
379 command.push_str(&shell_quote(profile));
380 }
381 next.recommended_command = command;
382 }
383 }
384 }
385
386 #[must_use]
387 pub fn artifact_file(&self, artifact: Artifact) -> Option<&PathBuf> {
388 match artifact {
389 Artifact::Body => self.body_file.as_ref(),
390 Artifact::RenderedHtml => self.rendered_html_file.as_ref(),
391 Artifact::Text => self.text_file.as_ref(),
392 Artifact::Content => self.content_file.as_ref(),
393 Artifact::ContentJson => self.content_json_file.as_ref(),
394 Artifact::Screenshot => self.screenshot_file.as_ref(),
395 Artifact::Network => self.network_file.as_ref(),
396 Artifact::Console => self.console_file.as_ref(),
397 Artifact::Observation => self.observation_file.as_ref(),
398 Artifact::Storage => self.storage_file.as_ref(),
399 }
400 }
401}
402
403fn shell_quote(value: &str) -> String {
404 if !value.is_empty()
405 && value.bytes().all(|b| {
406 b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.' | b'/' | b':' | b',' | b'=')
407 })
408 {
409 return value.to_string();
410 }
411 format!("'{}'", value.replace('\'', "'\\''"))
412}
413
414#[cfg(test)]
415mod tests {
416 use super::*;
417
418 #[test]
419 fn fetch_success_json_is_flat_golden() {
420 let mut result = FetchResult::new(
421 RequestId("req-1".into()),
422 "https://example.com/".into(),
423 Trace {
424 render_decision: RenderDecision::Browser,
425 render_mode: TraceRenderMode::Always,
426 render_used: true,
427 escalation_reason: None,
428 main_request_observed: true,
429 duration_ms: 12,
430 timeout_ms: 30000,
431 current_stage: "complete".into(),
432 navigation_duration_ms: Some(8),
433 wait_mode: Some("load".into()),
434 wait_satisfied_by: Some("load".into()),
435 network_quiet: None,
436 dom_stable: None,
437 text_stable: None,
438 capture_reason: Some("wait_satisfied".into()),
439 cookie_jar_file: None,
440 cookie_jar_warning: None,
441 sensitive_capture: Vec::new(),
442 stages: vec![
443 TraceStage {
444 name: "navigate".into(),
445 status: TraceStageStatus::Ok,
446 duration_ms: 8,
447 },
448 TraceStage {
449 name: "capture_text".into(),
450 status: TraceStageStatus::Ok,
451 duration_ms: 4,
452 },
453 ],
454 },
455 );
456 result.status = 200;
457 for (artifact, file) in [
458 (Artifact::Body, "body.html"),
459 (Artifact::RenderedHtml, "rendered.html"),
460 (Artifact::Text, "text.txt"),
461 (Artifact::Screenshot, "page.png"),
462 (Artifact::Network, "network.json"),
463 (Artifact::Console, "console.json"),
464 (Artifact::Observation, "observation.json"),
465 (Artifact::Storage, "storage.json"),
466 ] {
467 result.set_artifact_file(artifact, PathBuf::from("/tmp/afhttp-out/req-1").join(file));
468 }
469 let mut buf = Vec::new();
470 crate::shared::afdata::emit(&mut buf, "fetch", &result).unwrap();
471 let json: serde_json::Value = serde_json::from_slice(&buf).unwrap();
472 agent_first_data::validate_protocol_event(&json, true).unwrap();
473 assert!(
474 json["result"].get("artifacts").is_none(),
475 "artifacts map must be gone"
476 );
477 let canonical = serde_json::to_string(&json).unwrap();
478 let expected = include_str!("../../../tests/golden/fetch-success.json").trim();
479 assert_eq!(canonical, expected);
480 }
481
482 #[test]
483 fn bot_wall_page_kind_adds_human_takeover_next_action() {
484 let mut result = FetchResult::new(
485 RequestId("req-1".into()),
486 "https://example.com/a b".into(),
487 Trace::default(),
488 );
489 result.set_page_kind(PageKind::BotWallDetected);
490 let next = result.next_action.expect("next_action");
491 assert_eq!(next.kind, NextActionKind::HumanTakeover);
492 assert!(!next.target_content_verified);
493 assert_eq!(
494 next.recommended_command,
495 "afhttp fetch 'https://example.com/a b' --takeover"
496 );
497 }
498
499 #[test]
500 fn takeover_next_action_can_include_connection_and_profile_context() {
501 let mut result = FetchResult::new(
502 RequestId("req-1".into()),
503 "https://example.com/login".into(),
504 Trace::default(),
505 );
506 result.tab_id = Some(TabId::new("page 7"));
507 result.set_page_kind(PageKind::BotWallDetected);
508 result.attach_takeover_with_context(
509 "http://127.0.0.1:9222/takeover/panel?handoff_secret=h".into(),
510 Some("2026-06-11T00:00:00Z".into()),
511 Some(900),
512 Some("takeover".into()),
513 Some("ws://127.0.0.1:9222"),
514 Some("work profile"),
515 );
516
517 let next = result.next_action.expect("next_action");
518 assert_eq!(
519 next.recommended_command,
520 "afhttp fetch https://example.com/login --takeover --tab 'page 7' --endpoint-url ws://127.0.0.1:9222 --profile 'work profile'"
521 );
522 assert_eq!(
523 next.takeover_url_secret.as_deref(),
524 Some("http://127.0.0.1:9222/takeover/panel?handoff_secret=h")
525 );
526 assert_eq!(next.takeover_url_ttl_s, Some(900));
527 assert_eq!(next.takeover_url_scope.as_deref(), Some("takeover"));
528 }
529}