agent_first_http/sdk/fetch/pipeline/
mod.rs1mod 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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(rename_all = "snake_case")]
26pub enum RenderMode {
27 None,
29 #[default]
31 Auto,
32 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#[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}