agent_first_http/sdk/fetch/mod.rs
1//! Fetch pipeline: HTTP-only fast path, browser-backed render path
2//! (`--render none|auto|always`), and the fetch artifacts
3//! (`architecture.md §8`).
4
5pub mod artifacts;
6pub mod pipeline;
7pub mod result;
8pub mod wait;
9pub mod writer;
10
11pub type FetchCookie = cookie::Cookie<'static>;
12pub use cookie::SameSite as FetchCookieSameSite;
13pub use pipeline::{NetworkBodies, RenderMode};
14pub use result::FetchResult;
15pub use wait::Wait;
16
17use std::collections::BTreeSet;
18use std::path::PathBuf;
19use std::time::Duration;
20
21use crate::sdk::client::Client;
22use crate::shared::artifacts::Artifact;
23use crate::shared::error::Error;
24use crate::shared::ids::TabId;
25
26/// Builder for `Client::fetch(...).send().await`.
27#[derive(Clone)]
28pub struct FetchBuilder {
29 pub(crate) client: Client,
30 pub(crate) url: String,
31 pub(crate) render: RenderMode,
32 pub(crate) wait: Wait,
33 pub(crate) timeout: Duration,
34 pub(crate) want: BTreeSet<Artifact>,
35 pub(crate) tab: Option<TabId>,
36 pub(crate) network_bodies: NetworkBodies,
37 pub(crate) network_body_max_bytes: u64,
38 pub(crate) network_redact: bool,
39 pub(crate) request: RequestOptions,
40 pub(crate) out_dir: Option<PathBuf>,
41 /// Explicit cookie-jar path override. Normally the pipeline derives
42 /// `<profile>/cookies.jar.json` from the host's `GET /profile` —
43 /// setting this field tells the pipeline to use the given path
44 /// instead. The override must canonicalize to the host's profile
45 /// directory or the pipeline rejects with `invalid_argument`.
46 pub(crate) cookie_jar: Option<PathBuf>,
47 pub(crate) cookie_jar_warning: Option<String>,
48 /// Opt out of the cookie jar entirely for this fetch. Useful for
49 /// agents that want a clean request even when the host has a
50 /// persistent profile (e.g. recon traffic that should not carry
51 /// authenticated session cookies).
52 pub(crate) cookie_jar_disabled: bool,
53 /// Upper bound, in milliseconds, on how long the browser path
54 /// waits for the main document network event before falling back
55 /// to capturing artifacts with `main_request_observed: false`.
56 /// Default 500ms matches well-behaved pages on fast networks;
57 /// slow/loaded networks or low-end machines may need a longer cap.
58 pub(crate) observe_main_wait_ms: u64,
59 /// Upper bound, in bytes, on the HTTP fast path's response body
60 /// before the pipeline stops accumulating and emits a
61 /// `network_body_truncated` warning instead. Default 1 GiB —
62 /// generous enough that normal pages and downloads never trip
63 /// it, low enough that a pathological multi-GB download cannot
64 /// OOM the host. `0` disables the cap entirely.
65 pub(crate) max_response_bytes: u64,
66 /// Number of additional attempts after the first one. The fetch
67 /// is retried only when the error carries `retryable: true`. `0`
68 /// (the default) keeps the single-attempt behavior.
69 pub(crate) retry: u32,
70 /// Fixed delay between retry attempts, in milliseconds. Retry
71 /// orchestration beyond a fixed interval is the agent's job; the
72 /// tool just gives it the primitive.
73 pub(crate) backoff_ms: u64,
74 /// Per-fetch upstream proxy for the HTTP fast path. The SDK builds
75 /// a dedicated reqwest client when this (or `ca_cert` /
76 /// `tls_insecure`) is set so the per-Client default reqwest is
77 /// not contaminated. `None` keeps the default direct connection.
78 pub(crate) proxy: Option<String>,
79 /// Path to a PEM file containing extra root certificates to trust
80 /// for this fetch's HTTP path. Useful for fetching against
81 /// self-signed targets or corporate MITM CAs without weakening
82 /// the global trust store.
83 pub(crate) ca_cert: Option<PathBuf>,
84 /// Disable TLS certificate verification for the HTTP path. The
85 /// agent must opt in explicitly — this is dangerous and the CLI
86 /// help calls it out.
87 pub(crate) tls_insecure: bool,
88 /// Capture WebSocket frame payloads to `network-bodies/<id>.frames.jsonl`.
89 pub(crate) capture_ws: bool,
90 /// Capture SSE event payloads to `network-bodies/<id>.frames.jsonl`.
91 pub(crate) capture_sse: bool,
92}
93
94#[derive(Clone, Debug, Default)]
95pub(crate) struct RequestOptions {
96 pub(crate) headers: Vec<(String, String)>,
97 pub(crate) user_agent: Option<String>,
98 pub(crate) cookies: Vec<FetchCookie>,
99 pub(crate) evaluate_after_wait: Vec<String>,
100 /// HTTP method. `None` = GET (default). Uppercase recommended; the
101 /// pipeline normalises before sending.
102 pub(crate) method: Option<String>,
103 /// Raw request body bytes (mutually exclusive with `form`).
104 pub(crate) body: Option<Vec<u8>>,
105 /// Form fields sent as `application/x-www-form-urlencoded` (mutually
106 /// exclusive with `body`).
107 pub(crate) form: Vec<(String, String)>,
108}
109
110impl FetchBuilder {
111 pub(crate) fn new(client: Client, url: String) -> Self {
112 Self {
113 client,
114 url,
115 render: RenderMode::Auto,
116 wait: Wait::Load,
117 timeout: Duration::from_secs(30),
118 want: Artifact::ALL.iter().copied().collect(),
119 tab: None,
120 network_bodies: NetworkBodies::Off,
121 network_body_max_bytes: 1_048_576,
122 network_redact: true,
123 request: RequestOptions::default(),
124 out_dir: None,
125 cookie_jar: None,
126 cookie_jar_warning: None,
127 cookie_jar_disabled: false,
128 observe_main_wait_ms: 500,
129 max_response_bytes: 1_073_741_824,
130 retry: 0,
131 backoff_ms: 250,
132 proxy: None,
133 ca_cert: None,
134 tls_insecure: false,
135 capture_ws: false,
136 capture_sse: false,
137 }
138 }
139
140 #[must_use]
141 pub fn render(mut self, mode: RenderMode) -> Self {
142 self.render = mode;
143 self
144 }
145
146 #[must_use]
147 pub fn wait(mut self, w: Wait) -> Self {
148 self.wait = w;
149 self
150 }
151
152 #[must_use]
153 pub fn timeout(mut self, d: Duration) -> Self {
154 self.timeout = d;
155 self
156 }
157
158 #[must_use]
159 pub fn want<I: IntoIterator<Item = Artifact>>(mut self, items: I) -> Self {
160 self.want = items.into_iter().collect();
161 self
162 }
163
164 #[must_use]
165 pub fn tab(mut self, tab: TabId) -> Self {
166 self.tab = Some(tab);
167 self
168 }
169
170 #[must_use]
171 pub fn network_bodies(mut self, mode: NetworkBodies) -> Self {
172 self.network_bodies = mode;
173 self
174 }
175
176 #[must_use]
177 pub fn network_body_max_bytes(mut self, n: u64) -> Self {
178 self.network_body_max_bytes = n;
179 self
180 }
181
182 #[must_use]
183 pub fn network_redact(mut self, on: bool) -> Self {
184 self.network_redact = on;
185 self
186 }
187
188 /// Add a request header. `User-Agent` is normalized to
189 /// [`Self::user_agent`] at send time so browser fetches use the CDP UA
190 /// override instead of a plain extra header.
191 #[must_use]
192 pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
193 self.request.headers.push((name.into(), value.into()));
194 self
195 }
196
197 /// Add multiple request headers.
198 #[must_use]
199 pub fn headers<I, K, V>(mut self, headers: I) -> Self
200 where
201 I: IntoIterator<Item = (K, V)>,
202 K: Into<String>,
203 V: Into<String>,
204 {
205 self.request
206 .headers
207 .extend(headers.into_iter().map(|(k, v)| (k.into(), v.into())));
208 self
209 }
210
211 /// Override the browser/client user agent.
212 #[must_use]
213 pub fn user_agent(mut self, value: impl Into<String>) -> Self {
214 self.request.user_agent = Some(value.into());
215 self
216 }
217
218 /// Add a request cookie as a `name=value` pair.
219 #[must_use]
220 pub fn cookie(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
221 self.request
222 .cookies
223 .push(cookie::Cookie::new(name.into(), value.into()));
224 self
225 }
226
227 /// Add a full cookie, including optional Domain/Path/Secure/HttpOnly/
228 /// SameSite/Max-Age/Expires attributes.
229 #[must_use]
230 pub fn cookie_full(mut self, cookie: FetchCookie) -> Self {
231 self.request.cookies.push(cookie);
232 self
233 }
234
235 /// Add multiple request cookies as `name=value` pairs.
236 #[must_use]
237 pub fn cookies<I, K, V>(mut self, cookies: I) -> Self
238 where
239 I: IntoIterator<Item = (K, V)>,
240 K: Into<String>,
241 V: Into<String>,
242 {
243 self.request.cookies.extend(
244 cookies
245 .into_iter()
246 .map(|(k, v)| cookie::Cookie::new(k.into(), v.into())),
247 );
248 self
249 }
250
251 /// Evaluate JavaScript after the configured wait condition and before
252 /// artifact capture. Only browser-backed fetches can execute scripts.
253 #[must_use]
254 pub fn evaluate_after_wait(mut self, js: impl Into<String>) -> Self {
255 self.request.evaluate_after_wait.push(js.into());
256 self
257 }
258
259 #[must_use]
260 pub fn out_dir(mut self, dir: impl Into<PathBuf>) -> Self {
261 self.out_dir = Some(dir.into());
262 self
263 }
264
265 /// Override the cookie-jar path. The default — derived from the host's
266 /// `GET /profile` — places the jar at `<profile-dir>/cookies.jar.json`,
267 /// which is the only path the isolation invariant permits. This
268 /// override exists for tests and forensic tooling; the pipeline
269 /// canonicalizes the given path and rejects it with `invalid_argument`
270 /// if it doesn't match the host's profile directory.
271 #[must_use]
272 pub fn cookie_jar(mut self, path: impl Into<PathBuf>) -> Self {
273 self.cookie_jar = Some(path.into());
274 self
275 }
276
277 /// Opt out of cookie-jar persistence for this fetch. The request goes
278 /// out without any session cookies the jar might hold, and the
279 /// response's `Set-Cookie` headers are not merged back.
280 #[must_use]
281 pub fn no_cookie_jar(mut self) -> Self {
282 self.cookie_jar_disabled = true;
283 self
284 }
285
286 /// Upper bound on the browser-path wait for the main document
287 /// network event, in milliseconds. Default 500ms is tuned for
288 /// well-behaved pages on fast networks; raise for slow networks
289 /// or low-end machines.
290 #[must_use]
291 pub fn observe_main_wait_ms(mut self, ms: u64) -> Self {
292 self.observe_main_wait_ms = ms;
293 self
294 }
295
296 /// Upper bound on the HTTP-path response body, in bytes. Default
297 /// 1 GiB. `0` disables the cap entirely. When the cap is hit, the
298 /// fetch returns successfully with a `network_body_truncated`
299 /// warning and the prefix bytes that were collected.
300 #[must_use]
301 pub fn max_response_bytes(mut self, bytes: u64) -> Self {
302 self.max_response_bytes = bytes;
303 self
304 }
305
306 /// Number of additional attempts after the first. `0` (default)
307 /// keeps the single-attempt behavior. Retries only fire when the
308 /// pipeline error has `retryable: true`.
309 #[must_use]
310 pub fn retry(mut self, n: u32) -> Self {
311 self.retry = n;
312 self
313 }
314
315 /// Fixed delay between retry attempts, in milliseconds.
316 #[must_use]
317 pub fn backoff_ms(mut self, ms: u64) -> Self {
318 self.backoff_ms = ms;
319 self
320 }
321
322 /// Per-fetch upstream proxy URL for the HTTP path. Format:
323 /// `http://user:pass@host:port` or `socks5://host:port`. The SDK
324 /// never honors `HTTP_PROXY`/`HTTPS_PROXY` from the environment;
325 /// this method is the only way to route an HTTP-path fetch
326 /// through a proxy.
327 #[must_use]
328 pub fn proxy(mut self, url: impl Into<String>) -> Self {
329 self.proxy = Some(url.into());
330 self
331 }
332
333 /// Path to a PEM file containing extra root CAs to trust for
334 /// this fetch's HTTP path. Stacks on top of the platform trust
335 /// store; does not replace it.
336 #[must_use]
337 pub fn ca_cert(mut self, path: impl Into<PathBuf>) -> Self {
338 self.ca_cert = Some(path.into());
339 self
340 }
341
342 /// Disable TLS certificate verification for this fetch's HTTP
343 /// path. Dangerous — leaves the connection open to MITM. Use
344 /// only against known-self-signed staging environments.
345 #[must_use]
346 pub fn tls_insecure(mut self, on: bool) -> Self {
347 self.tls_insecure = on;
348 self
349 }
350
351 /// HTTP method. Defaults to `GET`. Pass `"POST"`, `"PUT"`, etc.
352 #[must_use]
353 pub fn method(mut self, m: impl Into<String>) -> Self {
354 self.request.method = Some(m.into());
355 self
356 }
357
358 /// Raw request body. Mutually exclusive with [`Self::form_field`].
359 /// Sets the body bytes as-is; add `Content-Type` via
360 /// [`Self::header`] when needed.
361 #[must_use]
362 pub fn body(mut self, data: impl Into<Vec<u8>>) -> Self {
363 self.request.body = Some(data.into());
364 self
365 }
366
367 /// Add a form field. Mutually exclusive with [`Self::body`]. The
368 /// pipeline sends the fields as `application/x-www-form-urlencoded`
369 /// and sets the content-type header automatically.
370 #[must_use]
371 pub fn form_field(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
372 self.request.form.push((key.into(), value.into()));
373 self
374 }
375
376 /// Capture WebSocket frame payloads to
377 /// `network-bodies/<request_id>.frames.jsonl` during the browser path.
378 #[must_use]
379 pub fn capture_ws(mut self, on: bool) -> Self {
380 self.capture_ws = on;
381 self
382 }
383
384 /// Capture SSE event payloads to
385 /// `network-bodies/<request_id>.frames.jsonl` during the browser path.
386 #[must_use]
387 pub fn capture_sse(mut self, on: bool) -> Self {
388 self.capture_sse = on;
389 self
390 }
391
392 /// Execute the fetch, with retries when configured. Retries only
393 /// fire for errors carrying `retryable: true`; any other error
394 /// short-circuits immediately.
395 pub async fn send(self) -> Result<FetchResult, Error> {
396 if self.retry == 0 {
397 return pipeline::execute(self).await;
398 }
399 let max_attempts = self.retry.saturating_add(1);
400 let delay = std::time::Duration::from_millis(self.backoff_ms);
401 let mut attempt: u32 = 0;
402 loop {
403 match pipeline::execute(self.clone()).await {
404 Ok(r) => return Ok(r),
405 Err(e) if e.retryable && attempt + 1 < max_attempts => {
406 tokio::time::sleep(delay).await;
407 attempt += 1;
408 }
409 Err(e) => return Err(e),
410 }
411 }
412 }
413}