Skip to main content

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}