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::FetchBuilder;
17use crate::sdk::fetch::deadline::FetchDeadline;
18use crate::sdk::fetch::result::{EscalationReason, FetchResult, RenderDecision};
19use crate::shared::artifacts::{Artifact, 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.out_dir.clone().unwrap_or_else(default_out_dir);
110    let paths = ArtifactPaths::new(out_root, &request_id);
111
112    let start = Instant::now();
113    match builder.render {
114        RenderMode::None => {
115            deadline.update_trace(|trace| {
116                trace.render_decision = RenderDecision::HttpOnly;
117                trace.render_used = false;
118            });
119            browser::reject_http_only_evaluate(&request_options)?;
120            http_only::http_only(
121                &builder,
122                &request_options,
123                request_id,
124                &paths,
125                start,
126                None,
127                &deadline,
128            )
129            .await
130            .map(|o| o.result)
131        }
132        RenderMode::Auto => {
133            deadline.update_trace(|trace| {
134                trace.render_decision = RenderDecision::HttpOnly;
135                trace.render_used = false;
136            });
137            match http_only::http_only(
138                &builder,
139                &request_options,
140                request_id.clone(),
141                &paths,
142                start,
143                None,
144                &deadline,
145            )
146            .await
147            {
148                Ok(o) if o.result.status < 400 => {
149                    if let Some(classification) =
150                        http_only::classify_http_body(&o.body_bytes, o.content_type.as_deref())
151                    {
152                        let reason = classification.code.as_str().to_string();
153                        deadline.update_trace(|trace| {
154                            trace.render_decision = RenderDecision::Browser;
155                            trace.render_used = true;
156                            trace.escalation_reason = Some(reason.clone());
157                        });
158                        return browser::browser_path(
159                            builder,
160                            request_options,
161                            request_id,
162                            paths,
163                            start,
164                            Some(reason),
165                            &deadline,
166                        )
167                        .await;
168                    }
169                    if http_only::looks_like_empty_html_shell(
170                        &o.body_bytes,
171                        o.content_type.as_deref(),
172                    ) {
173                        deadline.update_trace(|trace| {
174                            trace.render_decision = RenderDecision::Browser;
175                            trace.render_used = true;
176                            trace.escalation_reason =
177                                Some(EscalationReason::EMPTY_HTML_SHELL.to_string());
178                        });
179                        return browser::browser_path(
180                            builder,
181                            request_options,
182                            request_id,
183                            paths,
184                            start,
185                            Some(EscalationReason::EMPTY_HTML_SHELL.to_string()),
186                            &deadline,
187                        )
188                        .await;
189                    }
190                    if builder.want.contains(&Artifact::Content)
191                        || builder.want.contains(&Artifact::ContentJson)
192                    {
193                        let reason = "requested_content_artifact".to_string();
194                        deadline.update_trace(|trace| {
195                            trace.render_decision = RenderDecision::Browser;
196                            trace.render_used = true;
197                            trace.escalation_reason = Some(reason.clone());
198                        });
199                        return browser::browser_path(
200                            builder,
201                            request_options,
202                            request_id,
203                            paths,
204                            start,
205                            Some(reason),
206                            &deadline,
207                        )
208                        .await;
209                    }
210                    browser::reject_http_only_evaluate(&request_options)?;
211                    Ok(o.result)
212                }
213                Err(e) if e.error_code == ErrorCode::InvalidArgument => Err(e),
214                outcome => {
215                    let reason = match &outcome {
216                        Ok(o) => EscalationReason::http_status(o.result.status),
217                        Err(e) => EscalationReason::http_failed(e.error_code.as_str()),
218                    };
219                    deadline.update_trace(|trace| {
220                        trace.render_decision = RenderDecision::Browser;
221                        trace.render_used = true;
222                        trace.escalation_reason = Some(reason.clone());
223                    });
224                    browser::browser_path(
225                        builder,
226                        request_options,
227                        request_id,
228                        paths,
229                        start,
230                        Some(reason),
231                        &deadline,
232                    )
233                    .await
234                }
235            }
236        }
237        RenderMode::Always => {
238            deadline.update_trace(|trace| {
239                trace.render_decision = RenderDecision::Browser;
240                trace.render_used = true;
241            });
242            browser::browser_path(
243                builder,
244                request_options,
245                request_id,
246                paths,
247                start,
248                None,
249                &deadline,
250            )
251            .await
252        }
253    }
254}
255
256fn default_out_dir() -> std::path::PathBuf {
257    std::env::temp_dir().join("afhttp-out")
258}
259
260pub(super) fn sensitive_capture(builder: &FetchBuilder) -> Vec<String> {
261    let mut risks = Vec::new();
262    if !builder.network.redact {
263        risks.push("network_redact_off_may_expose_tokens_or_pii".to_string());
264    }
265    if builder.network.capture_ws {
266        risks.push("capture_ws_may_expose_tokens_or_pii".to_string());
267    }
268    if builder.network.capture_sse {
269        risks.push("capture_sse_may_expose_tokens_or_pii".to_string());
270    }
271    risks
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277
278    #[test]
279    fn render_mode_parses() {
280        assert_eq!(RenderMode::parse("none").unwrap(), RenderMode::None);
281        assert_eq!(RenderMode::parse("auto").unwrap(), RenderMode::Auto);
282        assert_eq!(RenderMode::parse("always").unwrap(), RenderMode::Always);
283        assert!(RenderMode::parse("rocket").is_err());
284    }
285
286    #[test]
287    fn network_bodies_parses() {
288        assert_eq!(NetworkBodies::parse("off").unwrap(), NetworkBodies::Off);
289        assert_eq!(NetworkBodies::parse("xhr").unwrap(), NetworkBodies::Xhr);
290        assert_eq!(NetworkBodies::parse("all").unwrap(), NetworkBodies::All);
291        assert!(NetworkBodies::parse("some").is_err());
292    }
293
294    #[test]
295    fn default_out_dir_is_under_system_temp() {
296        let dir = default_out_dir();
297        assert_eq!(dir, std::env::temp_dir().join("afhttp-out"));
298    }
299}