Skip to main content

agent_first_http/sdk/fetch/pipeline/
mod.rs

1//! Render-mode state machine.
2//!
3//! `RenderMode::None` uses the HTTP fast path (no browser). `Auto` tries
4//! HTTP first and escalates to the browser on connect failure / 5xx;
5//! `Always` skips the entire HTTP attempt.
6
7mod browser;
8mod cookie_jar_resolve;
9mod http_only;
10mod request_opts;
11
12use std::time::Instant;
13
14use serde::{Deserialize, Serialize};
15
16use crate::sdk::fetch::deadline::FetchDeadline;
17use crate::sdk::fetch::result::{EscalationReason, FetchResult, RenderDecision};
18use crate::sdk::fetch::FetchBuilder;
19use crate::shared::artifacts::ArtifactPaths;
20use crate::shared::error::{Error, ErrorCode};
21use crate::shared::ids::RequestId;
22
23/// `--render` modes from `architecture.md §5`.
24#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(rename_all = "snake_case")]
26pub enum RenderMode {
27    /// HTTP only; never start a browser.
28    None,
29    /// HTTP first; escalate to browser on connect/5xx/non-HTML.
30    #[default]
31    Auto,
32    /// Always use the browser.
33    Always,
34}
35
36impl RenderMode {
37    pub fn parse(s: &str) -> Result<Self, Error> {
38        match s {
39            "none" => Ok(Self::None),
40            "auto" => Ok(Self::Auto),
41            "always" => Ok(Self::Always),
42            other => Err(Error::new(
43                ErrorCode::InvalidArgument,
44                format!("--render: unknown mode {other:?}; expected none|auto|always"),
45            )),
46        }
47    }
48
49    pub(crate) fn as_trace(self) -> crate::sdk::fetch::result::TraceRenderMode {
50        match self {
51            Self::None => crate::sdk::fetch::result::TraceRenderMode::None,
52            Self::Auto => crate::sdk::fetch::result::TraceRenderMode::Auto,
53            Self::Always => crate::sdk::fetch::result::TraceRenderMode::Always,
54        }
55    }
56}
57
58/// `--network-bodies` modes from `architecture.md §8`.
59#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
60#[serde(rename_all = "snake_case")]
61pub enum NetworkBodies {
62    #[default]
63    Off,
64    Xhr,
65    All,
66}
67
68impl NetworkBodies {
69    pub fn parse(s: &str) -> Result<Self, Error> {
70        match s {
71            "off" => Ok(Self::Off),
72            "xhr" => Ok(Self::Xhr),
73            "all" => Ok(Self::All),
74            other => Err(Error::new(
75                ErrorCode::InvalidArgument,
76                format!("--network-bodies: unknown {other:?}; expected off|xhr|all"),
77            )),
78        }
79    }
80}
81
82pub(crate) async fn execute(
83    mut builder: FetchBuilder,
84    deadline: FetchDeadline,
85) -> Result<FetchResult, Error> {
86    deadline.update_trace(|trace| {
87        trace.render_mode = builder.render.as_trace();
88        trace.sensitive_capture = sensitive_capture(&builder);
89    });
90    deadline
91        .run_result(
92            "resolve_cookie_jar",
93            ErrorCode::NavigationTimeout,
94            cookie_jar_resolve::resolve_cookie_jar_path(&mut builder),
95        )
96        .await?;
97    deadline.update_trace(|trace| {
98        trace.cookie_jar_file = builder.cookie_jar.path.clone();
99        trace.cookie_jar_warning = builder.cookie_jar.warning.clone();
100    });
101    let request_options = deadline
102        .run_result(
103            "prepare_request",
104            ErrorCode::NavigationTimeout,
105            std::future::ready(request_opts::PreparedRequestOptions::from_builder(&builder)),
106        )
107        .await?;
108    let request_id = RequestId::new_v4();
109    let out_root = builder
110        .out_dir
111        .clone()
112        .unwrap_or_else(|| std::path::PathBuf::from("./afhttp-out"));
113    let paths = ArtifactPaths::new(out_root, &request_id);
114
115    let start = Instant::now();
116    match builder.render {
117        RenderMode::None => {
118            deadline.update_trace(|trace| {
119                trace.render_decision = RenderDecision::HttpOnly;
120                trace.render_used = false;
121            });
122            browser::reject_http_only_evaluate(&request_options)?;
123            http_only::http_only(
124                &builder,
125                &request_options,
126                request_id,
127                &paths,
128                start,
129                None,
130                &deadline,
131            )
132            .await
133            .map(|o| o.result)
134        }
135        RenderMode::Auto => {
136            deadline.update_trace(|trace| {
137                trace.render_decision = RenderDecision::HttpOnly;
138                trace.render_used = false;
139            });
140            match http_only::http_only(
141                &builder,
142                &request_options,
143                request_id.clone(),
144                &paths,
145                start,
146                None,
147                &deadline,
148            )
149            .await
150            {
151                Ok(o) if o.result.status < 400 => {
152                    if let Some(classification) =
153                        http_only::classify_http_body(&o.body_bytes, o.content_type.as_deref())
154                    {
155                        let reason = classification.code.as_str().to_string();
156                        deadline.update_trace(|trace| {
157                            trace.render_decision = RenderDecision::Browser;
158                            trace.render_used = true;
159                            trace.escalation_reason = Some(reason.clone());
160                        });
161                        return browser::browser_path(
162                            builder,
163                            request_options,
164                            request_id,
165                            paths,
166                            start,
167                            Some(reason),
168                            &deadline,
169                        )
170                        .await;
171                    }
172                    if http_only::looks_like_empty_html_shell(
173                        &o.body_bytes,
174                        o.content_type.as_deref(),
175                    ) {
176                        deadline.update_trace(|trace| {
177                            trace.render_decision = RenderDecision::Browser;
178                            trace.render_used = true;
179                            trace.escalation_reason =
180                                Some(EscalationReason::EMPTY_HTML_SHELL.to_string());
181                        });
182                        return browser::browser_path(
183                            builder,
184                            request_options,
185                            request_id,
186                            paths,
187                            start,
188                            Some(EscalationReason::EMPTY_HTML_SHELL.to_string()),
189                            &deadline,
190                        )
191                        .await;
192                    }
193                    browser::reject_http_only_evaluate(&request_options)?;
194                    Ok(o.result)
195                }
196                Err(e) if e.error_code == ErrorCode::InvalidArgument => Err(e),
197                outcome => {
198                    let reason = match &outcome {
199                        Ok(o) => EscalationReason::http_status(o.result.status),
200                        Err(e) => EscalationReason::http_failed(e.error_code.as_str()),
201                    };
202                    deadline.update_trace(|trace| {
203                        trace.render_decision = RenderDecision::Browser;
204                        trace.render_used = true;
205                        trace.escalation_reason = Some(reason.clone());
206                    });
207                    browser::browser_path(
208                        builder,
209                        request_options,
210                        request_id,
211                        paths,
212                        start,
213                        Some(reason),
214                        &deadline,
215                    )
216                    .await
217                }
218            }
219        }
220        RenderMode::Always => {
221            deadline.update_trace(|trace| {
222                trace.render_decision = RenderDecision::Browser;
223                trace.render_used = true;
224            });
225            browser::browser_path(
226                builder,
227                request_options,
228                request_id,
229                paths,
230                start,
231                None,
232                &deadline,
233            )
234            .await
235        }
236    }
237}
238
239pub(super) fn sensitive_capture(builder: &FetchBuilder) -> Vec<String> {
240    let mut risks = Vec::new();
241    if !builder.network.redact {
242        risks.push("network_redact_off_may_expose_tokens_or_pii".to_string());
243    }
244    if builder.network.capture_ws {
245        risks.push("capture_ws_may_expose_tokens_or_pii".to_string());
246    }
247    if builder.network.capture_sse {
248        risks.push("capture_sse_may_expose_tokens_or_pii".to_string());
249    }
250    risks
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256
257    #[test]
258    fn render_mode_parses() {
259        assert_eq!(RenderMode::parse("none").unwrap(), RenderMode::None);
260        assert_eq!(RenderMode::parse("auto").unwrap(), RenderMode::Auto);
261        assert_eq!(RenderMode::parse("always").unwrap(), RenderMode::Always);
262        assert!(RenderMode::parse("rocket").is_err());
263    }
264
265    #[test]
266    fn network_bodies_parses() {
267        assert_eq!(NetworkBodies::parse("off").unwrap(), NetworkBodies::Off);
268        assert_eq!(NetworkBodies::parse("xhr").unwrap(), NetworkBodies::Xhr);
269        assert_eq!(NetworkBodies::parse("all").unwrap(), NetworkBodies::All);
270        assert!(NetworkBodies::parse("some").is_err());
271    }
272}