Skip to main content

agent_first_http/cli/cmd/
fetch.rs

1//! `afhttp fetch` subcommand.
2
3use std::path::PathBuf;
4use std::time::Duration;
5
6use clap::Args as ClapArgs;
7use clap::ValueEnum;
8
9use crate::cli::cmd::argenums::{BrowserArg, RenderArg};
10use crate::cli::output;
11use crate::sdk::fetch::{
12    FetchCookie, NetworkBodies, RenderMode, Wait, DEFAULT_NETWORK_BODY_MAX_BYTES,
13};
14use crate::sdk::{Client, InlineConfig};
15use crate::shared::artifacts::Artifact;
16use crate::shared::error::{Error, ErrorCode};
17use crate::shared::ids::TabId;
18
19#[derive(ValueEnum, Debug, Clone, Copy, Default)]
20pub enum NetworkBodiesArg {
21    #[default]
22    Off,
23    Xhr,
24    All,
25}
26
27impl From<NetworkBodiesArg> for NetworkBodies {
28    fn from(v: NetworkBodiesArg) -> Self {
29        match v {
30            NetworkBodiesArg::Off => NetworkBodies::Off,
31            NetworkBodiesArg::Xhr => NetworkBodies::Xhr,
32            NetworkBodiesArg::All => NetworkBodies::All,
33        }
34    }
35}
36
37impl std::fmt::Display for NetworkBodiesArg {
38    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39        f.write_str(match self {
40            Self::Off => "off",
41            Self::Xhr => "xhr",
42            Self::All => "all",
43        })
44    }
45}
46
47#[derive(ClapArgs, Debug)]
48pub struct Args {
49    /// URL to fetch.
50    pub url: String,
51    /// CDP endpoint of a running host. Omit to spawn an inline ephemeral host
52    /// for ordinary browser fetches; with --takeover, omission discovers the
53    /// standard local `afhttp-host`. Falls back to `AFHTTP_ENDPOINT_URL`.
54    #[arg(
55        long = "endpoint-url",
56        env = "AFHTTP_ENDPOINT_URL",
57        help_heading = "Connection"
58    )]
59    pub endpoint: Option<String>,
60    /// Bearer token, if the host was started with `--token-secret`.
61    /// Falls back to `AFHTTP_TOKEN_SECRET`.
62    #[arg(
63        long = "token-secret",
64        env = "AFHTTP_TOKEN_SECRET",
65        help_heading = "Connection"
66    )]
67    pub token: Option<String>,
68    /// Browser backend for the inline host. Ignored when --endpoint-url is set
69    /// (the host owns its browser).
70    #[arg(long, default_value = "auto", help_heading = "Inline host")]
71    pub browser: BrowserArg,
72    /// Browser binary path for the inline host, for when auto-discovery can't
73    /// find one. Ignored when --endpoint-url is set.
74    #[arg(
75        long = "browser-bin",
76        value_name = "PATH",
77        help_heading = "Inline host"
78    )]
79    pub browser_bin: Option<PathBuf>,
80    /// Render strategy: none (HTTP fast path, no browser), auto (HTTP first,
81    /// escalate to the browser on failure), or always (browser only).
82    #[arg(long, default_value = "auto", help_heading = "Rendering")]
83    pub render: RenderArg,
84    /// Tab target: "new" allocates a temporary target and closes it after
85    /// fetch; a CDP target id reuses that target and leaves it open (the same
86    /// id `afhttp cdp`/`upload`/`tabs` accept).
87    #[arg(
88        long,
89        default_value = "new",
90        value_name = "new|<id>",
91        help_heading = "Session"
92    )]
93    pub tab: String,
94    /// Escalate to human takeover when a wall (captcha/login/2FA) is hit: keep
95    /// a persistent tab open and return its short-lived takeover URL in
96    /// `next_action`, plus a re-fetch command for the same tab once the human
97    /// clears the wall. Uses `--endpoint-url` / `AFHTTP_ENDPOINT_URL` when set;
98    /// otherwise discovers the standard local `afhttp-host` container (build
99    /// one with `afhttp container install`).
100    #[arg(long, help_heading = "Session")]
101    pub takeover: bool,
102    /// Host profile to use for this fetch. Switches the host's active profile
103    /// if it differs (per-domain isolation), relaunching its browser. With
104    /// `--takeover` and no `--profile`, the profile defaults to the URL's
105    /// registrable domain (eTLD+1). Requires a host via `--endpoint-url`, or
106    /// the standard local takeover host discovered by `--takeover`.
107    #[arg(long, help_heading = "Session")]
108    pub profile: Option<String>,
109    /// Readiness signal before capture on the browser path:
110    /// auto | load | idle | selector:<css> | selector-visible:<css> | ms:<n>.
111    #[arg(long, default_value = "auto", help_heading = "Rendering")]
112    pub wait: String,
113    /// Add a request header (repeatable). Format: `Name:value` (a space after
114    /// the colon is allowed).
115    #[arg(long = "header", value_name = "NAME:VALUE", help_heading = "Request")]
116    pub headers: Vec<String>,
117    /// Add a request cookie (repeatable). Format: `name=value`.
118    #[arg(long = "cookie", value_name = "NAME=VALUE", help_heading = "Request")]
119    pub cookies: Vec<String>,
120    /// Override the User-Agent header for this fetch.
121    #[arg(long, help_heading = "Request")]
122    pub user_agent: Option<String>,
123    /// JavaScript to evaluate after the wait condition resolves (repeatable).
124    /// Runs in page context before artifacts are captured.
125    #[arg(long, value_name = "JS", help_heading = "Rendering")]
126    pub evaluate_after_wait: Vec<String>,
127    /// Artifacts to capture, comma-separated. Default: body, rendered_html,
128    /// text, content, content_json, screenshot, network, console,
129    /// observation. `content` is the agent-oriented composed page view
130    /// (content.md); `content_json` its structured form with link/action
131    /// candidates. `storage` is opt-in (sensitive: localStorage/IndexedDB).
132    #[arg(long, value_delimiter = ',', help_heading = "Rendering")]
133    pub want: Vec<String>,
134    /// HTTP method. Common values: POST, PUT, PATCH, DELETE.
135    #[arg(long, default_value = "GET", help_heading = "Request")]
136    pub method: String,
137    /// Request body as a string. Prefix with `@` to read from a file path
138    /// (e.g. `--data @payload.json`). Mutually exclusive with `--form`.
139    #[arg(long, value_name = "STRING|@FILE", help_heading = "Request")]
140    pub data: Option<String>,
141    /// Add a form field (repeatable). Sends body as
142    /// `application/x-www-form-urlencoded`. Mutually exclusive with `--data`.
143    /// Format: `name=value`.
144    #[arg(long = "form", value_name = "NAME=VALUE", help_heading = "Request")]
145    pub form: Vec<String>,
146    /// Capture response bodies for network requests: off, xhr (XHR/fetch
147    /// only), or all.
148    #[arg(long, default_value_t = NetworkBodiesArg::Off, help_heading = "Network capture")]
149    pub network_bodies: NetworkBodiesArg,
150    /// Per-body cap for each captured network sub-request body, in bytes (see
151    /// `--max-response-bytes` for the main HTTP-path response body).
152    #[arg(long, default_value_t = DEFAULT_NETWORK_BODY_MAX_BYTES, help_heading = "Network capture")]
153    pub network_body_max_bytes: u64,
154    /// Network quiet window used by --wait auto, in milliseconds.
155    #[arg(long, default_value_t = 800, help_heading = "Readiness tuning")]
156    pub readiness_idle_ms: u64,
157    /// DOM/text unchanged window used by --wait auto, in milliseconds.
158    #[arg(long, default_value_t = 500, help_heading = "Readiness tuning")]
159    pub readiness_stable_ms: u64,
160    /// Low visible-text byte threshold for --wait auto quality warnings only.
161    #[arg(long, default_value_t = 32, help_heading = "Readiness tuning")]
162    pub readiness_min_text_bytes: u64,
163    /// Disable redaction of sensitive values in network.json (redacted by
164    /// default). Writes raw Authorization/Cookie headers and token-bearing
165    /// query params to the artifact — only for trusted local debugging.
166    #[arg(long, help_heading = "Network capture")]
167    pub no_network_redact: bool,
168    /// Directory to write artifacts into. Defaults to `afhttp-out` under the
169    /// system temporary directory. Files persist there for inspection.
170    #[arg(long, help_heading = "Output")]
171    pub out: Option<PathBuf>,
172    /// Override the cookie-jar path. The default — derived from the host's
173    /// `GET /profile` — places the jar at `<profile-dir>/cookies.jar.json`.
174    /// This override is rejected with `invalid_argument` if it does not
175    /// match the host's profile path; the flag exists for tests and
176    /// forensic tooling, not production sessions. Honors
177    /// `AFHTTP_COOKIE_JAR` when omitted (same validation applies).
178    #[arg(long, help_heading = "Cookies")]
179    pub cookie_jar: Option<PathBuf>,
180    /// Opt out of cookie-jar persistence for this fetch. No cookies are
181    /// replayed from the jar and no `Set-Cookie` responses are merged
182    /// back.
183    #[arg(long, help_heading = "Cookies")]
184    pub no_cookie_jar: bool,
185    /// Upper bound on the browser-path wait for the main document network
186    /// event, in milliseconds. Raise for slow networks or low-end machines.
187    #[arg(long, default_value_t = 500, help_heading = "Readiness tuning")]
188    pub observe_main_wait_ms: u64,
189    /// Upper bound on the main HTTP-path response body, in bytes (see
190    /// `--network-body-max-bytes` for captured network sub-request bodies).
191    /// Default 1 GiB (`1073741824`). `0` disables the cap entirely. When the
192    /// cap is hit, the fetch returns successfully with a
193    /// `network_body_truncated` warning and the prefix bytes that
194    /// were collected.
195    #[arg(long, default_value_t = 1_073_741_824, help_heading = "HTTP transport")]
196    pub max_response_bytes: u64,
197    /// Number of additional attempts after the first. Retries fire
198    /// only when the error has `retryable: true` (e.g.
199    /// `host_unreachable`, `cdp_timeout`); non-retryable failures
200    /// (`tls_error`, `wait_selector_unmatched`, etc.) short-circuit.
201    /// Default 0 = single attempt.
202    #[arg(long, default_value_t = 0, help_heading = "Retry")]
203    pub retry: u32,
204    /// Fixed delay between retries, in milliseconds.
205    #[arg(long, default_value_t = 250, help_heading = "Retry")]
206    pub backoff_ms: u64,
207    /// Per-fetch upstream HTTP/HTTPS proxy for the HTTP fast path.
208    /// The SDK never honors `HTTP_PROXY` from the environment; this
209    /// flag is the only way to route an HTTP-path fetch through one.
210    /// Format: `http://user:pass@host:port` or `socks5://host:port`.
211    #[arg(long = "proxy-url", help_heading = "HTTP transport")]
212    pub proxy: Option<String>,
213    /// Path to a PEM file containing extra root CAs to trust for
214    /// this fetch's HTTP path. Useful for self-signed staging or
215    /// corporate MITM CAs.
216    #[arg(long, help_heading = "HTTP transport")]
217    pub ca_cert: Option<PathBuf>,
218    /// Disable TLS certificate verification for this fetch's HTTP
219    /// path. Dangerous; leaves the connection open to MITM. Use only
220    /// against known-self-signed environments.
221    #[arg(long, help_heading = "HTTP transport")]
222    pub tls_insecure: bool,
223    /// Overall fetch timeout, in milliseconds. Applies to both the HTTP fast
224    /// path and the browser path.
225    #[arg(
226        long = "timeout-ms",
227        default_value_t = 30_000,
228        help_heading = "HTTP transport"
229    )]
230    pub timeout_ms: u64,
231    /// Capture WebSocket frame payloads to network-bodies/<id>.frames.jsonl.
232    /// Frames may carry bearer tokens, session IDs, and message content —
233    /// treat the artifact as sensitive.
234    #[arg(long, help_heading = "Network capture")]
235    pub capture_ws: bool,
236    /// Capture SSE event payloads to network-bodies/<id>.frames.jsonl. Events
237    /// may carry PII; treat the artifact as sensitive.
238    #[arg(long, help_heading = "Network capture")]
239    pub capture_sse: bool,
240}
241
242pub async fn run(args: Args) -> Result<(), Error> {
243    match run_inner(args).await {
244        Ok(()) => Ok(()),
245        Err(FetchRunError::Plain(err)) => {
246            let stdout = std::io::stdout();
247            let mut handle = stdout.lock();
248            let _ = crate::shared::envelope::emit_error(&mut handle, &err);
249            Err(err)
250        }
251        Err(FetchRunError::Emitted(err)) => Err(err),
252    }
253}
254
255async fn run_inner(mut args: Args) -> Result<(), FetchRunError> {
256    let render: RenderMode = args.render.into();
257    prepare_takeover_connection(&mut args, render, |token| async move {
258        crate::cli::cmd::container::discover_default_takeover_host(token.as_deref()).await
259    })
260    .await?;
261    // Resolve the host profile: explicit --profile wins; otherwise --takeover
262    // derives the URL's registrable domain (eTLD+1) for per-domain isolation.
263    let explicit_profile = args.profile.clone();
264    let resolved_profile: Option<String> = if let Some(p) = explicit_profile.clone() {
265        Some(p)
266    } else if args.takeover {
267        Some(default_profile_for_url(&args.url)?)
268    } else {
269        None
270    };
271    if resolved_profile.is_some() && args.endpoint.is_none() {
272        return Err(Error::new(
273            ErrorCode::InvalidArgument,
274            "--profile (and --takeover profile derivation) switch the host's active profile and require a host; pass --endpoint-url or set AFHTTP_ENDPOINT_URL",
275        )
276        .into());
277    }
278    let takeover = args.takeover;
279    let takeover_endpoint = args.endpoint.clone();
280    let takeover_token = args.token.clone();
281    let wait = Wait::parse(&args.wait)?;
282    let timeout = Duration::from_millis(args.timeout_ms);
283    let network_bodies = NetworkBodies::from(args.network_bodies);
284    let network_redact = !args.no_network_redact;
285
286    let body_bytes = resolve_body(&args).await?;
287    let want = resolve_want(&args.want)?;
288    let mut client = build_client(&args, render).await?;
289    if let Some(profile) = &resolved_profile {
290        client = client.with_profile(profile.clone());
291    }
292
293    let mut builder = client
294        .fetch(args.url.clone())
295        .render(render)
296        .wait(wait)
297        .timeout(timeout)
298        .want(want)
299        .network_bodies(network_bodies)
300        .network_body_max_bytes(args.network_body_max_bytes)
301        .readiness_idle_ms(args.readiness_idle_ms)
302        .readiness_stable_ms(args.readiness_stable_ms)
303        .readiness_min_text_bytes(args.readiness_min_text_bytes)
304        .network_redact(network_redact)
305        .method(args.method);
306    if let Some(bytes) = body_bytes {
307        builder = builder.body(bytes);
308    }
309    for raw in &args.form {
310        let (k, v) = raw.split_once('=').ok_or_else(|| {
311            Error::new(
312                ErrorCode::InvalidArgument,
313                format!("--form: expected key=value, got {raw:?}"),
314            )
315        })?;
316        builder = builder.form_field(k, v);
317    }
318    for raw in args.headers {
319        let (name, value) = parse_header_arg(&raw)?;
320        builder = builder.header(name, value);
321    }
322    for raw in args.cookies {
323        builder = builder.cookie_full(parse_cookie_arg(&raw)?);
324    }
325    if let Some(user_agent) = args.user_agent {
326        builder = builder.user_agent(user_agent);
327    }
328    for js in args.evaluate_after_wait {
329        builder = builder.evaluate_after_wait(js);
330    }
331    if args.tab != "new" {
332        builder = builder.tab(TabId::new(args.tab));
333    }
334    if takeover {
335        // Keep the prepared tab open so a human can take it over.
336        builder = builder.keep_tab_open(true);
337    }
338    if let Some(out) = args.out {
339        builder = builder.out_dir(out);
340    }
341    builder = builder.observe_main_wait_ms(args.observe_main_wait_ms);
342    builder = builder.max_response_bytes(args.max_response_bytes);
343    builder = builder.retry(args.retry).backoff_ms(args.backoff_ms);
344    if let Some(url) = args.proxy {
345        builder = builder.proxy(url);
346    }
347    if let Some(path) = args.ca_cert {
348        builder = builder.ca_cert(path);
349    }
350    if args.tls_insecure {
351        builder = builder.tls_insecure(true);
352    }
353    if args.capture_ws {
354        builder = builder.capture_ws(true);
355    }
356    if args.capture_sse {
357        builder = builder.capture_sse(true);
358    }
359    if args.no_cookie_jar {
360        builder = builder.no_cookie_jar();
361    } else {
362        let cookie_jar = args.cookie_jar.or_else(|| {
363            std::env::var_os("AFHTTP_COOKIE_JAR")
364                .filter(|v| !v.is_empty())
365                .map(PathBuf::from)
366        });
367        if let Some(jar) = cookie_jar {
368            builder = builder.cookie_jar(jar);
369        }
370    }
371
372    match builder.send_detailed().await {
373        Ok(mut result) => {
374            if takeover && result.next_action.is_some() {
375                if let Some(endpoint) = takeover_endpoint.as_deref() {
376                    let mut handoff_client = Client::connect(endpoint)?;
377                    if let Some(token) = takeover_token.as_deref() {
378                        handoff_client = handoff_client.with_token(token);
379                    }
380                    let tab_id = result.tab_id.as_ref().map(|t| t.as_str().to_string());
381                    let handoff = handoff_client
382                        .takeover_handoff(None, tab_id.as_deref())
383                        .await?;
384                    result.attach_takeover_with_context(
385                        handoff.takeover_url,
386                        Some(handoff.takeover_url_expires_at_rfc3339),
387                        Some(handoff.takeover_url_ttl_s),
388                        Some(handoff.takeover_url_scope),
389                        Some(endpoint),
390                        explicit_profile.as_deref(),
391                    );
392                }
393            }
394            Ok(output::emit("fetch", &result)?)
395        }
396        Err(err) => {
397            output::emit("error", &err)?;
398            Err(FetchRunError::Emitted(err.into_error()))
399        }
400    }
401}
402
403async fn prepare_takeover_connection<D, Fut>(
404    args: &mut Args,
405    render: RenderMode,
406    discover: D,
407) -> Result<(), Error>
408where
409    D: FnOnce(Option<String>) -> Fut,
410    Fut: std::future::Future<Output = Result<crate::cli::cmd::container::LocalTakeoverHost, Error>>,
411{
412    if !args.takeover {
413        return Ok(());
414    }
415    if matches!(render, RenderMode::None) {
416        return Err(Error::new(
417            ErrorCode::InvalidArgument,
418            "fetch --takeover needs a browser render; use --render auto or always",
419        ));
420    }
421    if args.endpoint.is_none() {
422        let discovered = discover(args.token.clone()).await?;
423        args.endpoint = Some(discovered.endpoint);
424        if args.token.is_none() {
425            args.token = discovered.token_secret;
426        }
427    }
428    Ok(())
429}
430
431/// Derive a default host profile from a URL's registrable domain (eTLD+1), so
432/// `fetch --takeover` gives each site its own isolated browser profile when
433/// `--profile` is omitted. Falls back to the full host for public-suffix
434/// tenants (e.g. `foo.github.io`) and bare IPs.
435fn default_profile_for_url(raw_url: &str) -> Result<String, Error> {
436    let parsed = url::Url::parse(raw_url).map_err(|e| {
437        Error::new(
438            ErrorCode::InvalidArgument,
439            format!(
440                "--takeover default profile needs a valid URL with a host; \
441                 could not parse {raw_url:?}: {e}; pass --profile <name>"
442            ),
443        )
444    })?;
445    let host = parsed.host().ok_or_else(|| {
446        Error::new(
447            ErrorCode::InvalidArgument,
448            format!(
449                "--takeover default profile needs URL {raw_url:?} to include a host; \
450                 pass --profile <name>"
451            ),
452        )
453    })?;
454    let (normalized_host, dns_name) = match host {
455        url::Host::Domain(domain) => (normalize_profile_host(domain), true),
456        url::Host::Ipv4(addr) => (addr.to_string(), false),
457        url::Host::Ipv6(addr) => (addr.to_string(), false),
458    };
459    let profile = if dns_name {
460        psl::domain_str(&normalized_host)
461            .unwrap_or(&normalized_host)
462            .to_string()
463    } else {
464        normalized_host.clone()
465    };
466    crate::sdk::profile::paths::validate_name(&profile).map_err(|e| {
467        Error::new(
468            e.error_code,
469            format!(
470                "derived --takeover profile {profile:?} from URL host {normalized_host:?} \
471                 is invalid: {}; pass --profile <name>",
472                e.detail
473            ),
474        )
475    })?;
476    Ok(profile)
477}
478
479fn normalize_profile_host(host: &str) -> String {
480    host.trim_end_matches('.').to_ascii_lowercase()
481}
482
483enum FetchRunError {
484    Plain(Error),
485    Emitted(Error),
486}
487
488impl From<Error> for FetchRunError {
489    fn from(err: Error) -> Self {
490        Self::Plain(err)
491    }
492}
493
494/// Resolve the request body from `--data` (literal, or `@path` to read a file),
495/// rejecting coexistence with `--form`. `--form` fields are wired separately by
496/// the caller; this only enforces that they don't coexist with a raw body.
497async fn resolve_body(args: &Args) -> Result<Option<Vec<u8>>, Error> {
498    if args.data.is_some() && !args.form.is_empty() {
499        return Err(Error::new(
500            ErrorCode::InvalidArgument,
501            "--data and --form are mutually exclusive",
502        ));
503    }
504    if let Some(data) = &args.data {
505        if let Some(path) = data.strip_prefix('@') {
506            return Ok(Some(tokio::fs::read(path).await.map_err(|e| {
507                Error::new(ErrorCode::IoError, format!("--data @{path}: {e}"))
508            })?));
509        }
510        return Ok(Some(data.as_bytes().to_vec()));
511    }
512    Ok(None)
513}
514
515/// Parse the `--want` tokens into an artifact set; an empty list means all
516/// default artifacts.
517fn resolve_want(want: &[String]) -> Result<std::collections::BTreeSet<Artifact>, Error> {
518    if want.is_empty() {
519        return Ok(Artifact::ALL.iter().copied().collect());
520    }
521    want.iter().map(|t| parse_artifact(t)).collect()
522}
523
524/// Construct the SDK client for this fetch: a remote connection when
525/// `--endpoint-url` is set, the HTTP-only client for `--render none`, or an
526/// inline ephemeral host otherwise (lazy for `auto`, eager for `always`).
527async fn build_client(args: &Args, render: RenderMode) -> Result<Client, Error> {
528    match args.endpoint.as_deref() {
529        Some(ep) => {
530            let mut c = Client::connect(ep)?;
531            if let Some(t) = args.token.as_deref() {
532                c = c.with_token(t);
533            }
534            Ok(c)
535        }
536        None if matches!(render, RenderMode::None) => Client::http_only(),
537        None => {
538            let cfg = InlineConfig {
539                browser: args.browser.into(),
540                browser_bin: args.browser_bin.clone(),
541            };
542            if matches!(render, RenderMode::Auto) {
543                Client::inline_ephemeral_lazy(cfg).await
544            } else {
545                Client::inline_ephemeral_with(cfg).await
546            }
547        }
548    }
549}
550
551fn parse_artifact(token: &str) -> Result<Artifact, Error> {
552    Ok(match token {
553        "body" => Artifact::Body,
554        "rendered_html" => Artifact::RenderedHtml,
555        "text" => Artifact::Text,
556        "content" => Artifact::Content,
557        "content_json" => Artifact::ContentJson,
558        "screenshot" => Artifact::Screenshot,
559        "network" => Artifact::Network,
560        "console" => Artifact::Console,
561        "observation" => Artifact::Observation,
562        "storage" => Artifact::Storage,
563        other => {
564            return Err(Error::new(
565                ErrorCode::InvalidArgument,
566                format!("--want: unknown artifact {other:?}"),
567            ));
568        }
569    })
570}
571
572fn parse_header_arg(raw: &str) -> Result<(String, String), Error> {
573    let (name, value) = raw.split_once(':').ok_or_else(|| {
574        Error::new(
575            ErrorCode::InvalidArgument,
576            format!("--header: expected K:V, got {raw:?}"),
577        )
578    })?;
579    let name = name.trim();
580    if name.is_empty() {
581        return Err(Error::new(
582            ErrorCode::InvalidArgument,
583            format!("--header: header name must not be empty in {raw:?}"),
584        ));
585    }
586    Ok((name.to_string(), value.trim_start().to_string()))
587}
588
589fn parse_cookie_arg(raw: &str) -> Result<FetchCookie, Error> {
590    if !raw.contains('=') {
591        return Err(Error::new(
592            ErrorCode::InvalidArgument,
593            format!("--cookie: expected Set-Cookie style name=value, got {raw:?}"),
594        ));
595    }
596    let cookie = FetchCookie::parse(raw.to_string())
597        .map_err(|e| Error::new(ErrorCode::InvalidArgument, format!("--cookie: {e}")))?
598        .into_owned();
599    if cookie.name().trim().is_empty() {
600        return Err(Error::new(
601            ErrorCode::InvalidArgument,
602            format!("--cookie: cookie name must not be empty in {raw:?}"),
603        ));
604    }
605    Ok(cookie)
606}
607
608#[cfg(test)]
609mod tests {
610    use super::*;
611
612    fn base_args(url: &str) -> Args {
613        Args {
614            url: url.to_string(),
615            endpoint: None,
616            token: None,
617            browser: BrowserArg::Auto,
618            browser_bin: None,
619            render: RenderArg::Auto,
620            tab: "new".into(),
621            takeover: false,
622            profile: None,
623            wait: "auto".into(),
624            headers: Vec::new(),
625            cookies: Vec::new(),
626            user_agent: None,
627            evaluate_after_wait: Vec::new(),
628            want: Vec::new(),
629            method: "GET".into(),
630            data: None,
631            form: Vec::new(),
632            network_bodies: NetworkBodiesArg::Off,
633            network_body_max_bytes: DEFAULT_NETWORK_BODY_MAX_BYTES,
634            readiness_idle_ms: 800,
635            readiness_stable_ms: 500,
636            readiness_min_text_bytes: 32,
637            no_network_redact: false,
638            out: None,
639            cookie_jar: None,
640            no_cookie_jar: false,
641            observe_main_wait_ms: 500,
642            max_response_bytes: 1_073_741_824,
643            retry: 0,
644            backoff_ms: 250,
645            proxy: None,
646            ca_cert: None,
647            tls_insecure: false,
648            timeout_ms: 30_000,
649            capture_ws: false,
650            capture_sse: false,
651        }
652    }
653
654    #[tokio::test]
655    async fn takeover_autodiscovery_fills_missing_endpoint_and_token() {
656        let mut args = base_args("https://contabo.com");
657        args.takeover = true;
658        prepare_takeover_connection(&mut args, RenderMode::Auto, |token| async move {
659            assert_eq!(token, None);
660            Ok(crate::cli::cmd::container::LocalTakeoverHost {
661                endpoint: "ws://127.0.0.1:9222".into(),
662                token_secret: Some("secret".into()),
663            })
664        })
665        .await
666        .unwrap();
667
668        assert_eq!(args.endpoint.as_deref(), Some("ws://127.0.0.1:9222"));
669        assert_eq!(args.token.as_deref(), Some("secret"));
670    }
671
672    #[tokio::test]
673    async fn takeover_autodiscovery_preserves_existing_token() {
674        let mut args = base_args("https://contabo.com");
675        args.takeover = true;
676        args.token = Some("env-token".into());
677        prepare_takeover_connection(&mut args, RenderMode::Auto, |token| async move {
678            assert_eq!(token.as_deref(), Some("env-token"));
679            Ok(crate::cli::cmd::container::LocalTakeoverHost {
680                endpoint: "ws://127.0.0.1:9222".into(),
681                token_secret: Some("container-token".into()),
682            })
683        })
684        .await
685        .unwrap();
686
687        assert_eq!(args.endpoint.as_deref(), Some("ws://127.0.0.1:9222"));
688        assert_eq!(args.token.as_deref(), Some("env-token"));
689    }
690
691    #[tokio::test]
692    async fn takeover_autodiscovery_surfaces_failure() {
693        let mut args = base_args("https://contabo.com");
694        args.takeover = true;
695        let err = prepare_takeover_connection(&mut args, RenderMode::Auto, |_| async {
696            Err(Error::new(
697                ErrorCode::InvalidArgument,
698                "default local container `afhttp-host` is not running",
699            ))
700        })
701        .await
702        .err()
703        .unwrap();
704
705        assert_eq!(err.error_code, ErrorCode::InvalidArgument);
706        assert!(err.detail.contains("afhttp-host"));
707        assert!(args.endpoint.is_none());
708    }
709
710    #[tokio::test]
711    async fn takeover_autodiscovery_rejects_render_none() {
712        let mut args = base_args("https://contabo.com");
713        args.takeover = true;
714        let err = prepare_takeover_connection(&mut args, RenderMode::None, |_| async {
715            Ok(crate::cli::cmd::container::LocalTakeoverHost {
716                endpoint: "ws://127.0.0.1:9222".into(),
717                token_secret: None,
718            })
719        })
720        .await
721        .err()
722        .unwrap();
723
724        assert_eq!(err.error_code, ErrorCode::InvalidArgument);
725        assert!(err.detail.contains("browser render"));
726    }
727
728    #[test]
729    fn default_profile_uses_registrable_domain() {
730        assert_eq!(
731            default_profile_for_url("https://www.court.gov.cn/foo").unwrap(),
732            "court.gov.cn"
733        );
734        assert_eq!(
735            default_profile_for_url("https://accounts.google.com/foo").unwrap(),
736            "google.com"
737        );
738        assert_eq!(
739            default_profile_for_url("https://contabo.com").unwrap(),
740            "contabo.com"
741        );
742    }
743
744    #[test]
745    fn default_profile_keeps_public_suffix_tenants_isolated() {
746        assert_eq!(
747            default_profile_for_url("https://foo.github.io/x").unwrap(),
748            "foo.github.io"
749        );
750        assert_eq!(
751            default_profile_for_url("https://tenant.vercel.app/x").unwrap(),
752            "tenant.vercel.app"
753        );
754    }
755
756    #[test]
757    fn default_profile_normalizes_case_and_trailing_dot() {
758        assert_eq!(
759            default_profile_for_url("https://WWW.Example.COM./foo").unwrap(),
760            "example.com"
761        );
762    }
763
764    #[test]
765    fn default_profile_falls_back_to_full_host_for_psl_misses_and_ips() {
766        assert_eq!(
767            default_profile_for_url("http://localhost:8080/foo").unwrap(),
768            "localhost"
769        );
770        assert_eq!(
771            default_profile_for_url("http://127.0.0.1:8080/foo").unwrap(),
772            "127.0.0.1"
773        );
774    }
775
776    #[test]
777    fn default_profile_errors_when_host_is_missing() {
778        assert!(default_profile_for_url("file:///tmp/page.html").is_err());
779    }
780
781    #[test]
782    fn header_arg_accepts_colon_separator() {
783        assert_eq!(
784            parse_header_arg("X-Test: yes").unwrap(),
785            ("X-Test".to_string(), "yes".to_string())
786        );
787    }
788
789    #[test]
790    fn header_arg_rejects_missing_colon() {
791        let err = parse_header_arg("X-Test").err().unwrap();
792        assert_eq!(err.error_code, ErrorCode::InvalidArgument);
793    }
794
795    #[test]
796    fn cookie_arg_accepts_equals_separator() {
797        let cookie = parse_cookie_arg("sid=abc=def").unwrap();
798        assert_eq!(cookie.name_value(), ("sid", "abc=def"));
799    }
800
801    #[test]
802    fn cookie_arg_accepts_full_set_cookie_attributes() {
803        let cookie = parse_cookie_arg("sid=abc; Path=/; Secure; HttpOnly; SameSite=Lax").unwrap();
804        assert_eq!(cookie.name_value(), ("sid", "abc"));
805        assert_eq!(cookie.path(), Some("/"));
806        assert_eq!(cookie.secure(), Some(true));
807        assert_eq!(cookie.http_only(), Some(true));
808        assert_eq!(cookie.same_site(), Some(cookie::SameSite::Lax));
809    }
810
811    #[test]
812    fn cookie_arg_rejects_missing_equals() {
813        let err = parse_cookie_arg("sid").err().unwrap();
814        assert_eq!(err.error_code, ErrorCode::InvalidArgument);
815    }
816}