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