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 on the HTTP fast
128    /// path; browser default artifacts when rendering is used. `content` is the
129    /// agent-oriented composed page view (content.md); `content_json` its
130    /// structured form with link/action candidates. `storage` is opt-in
131    /// (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    let endpoint_was_preconfigured = args.endpoint.is_some();
258    prepare_takeover_connection(&mut args, render, |token| async move {
259        crate::cli::cmd::container::discover_default_takeover_host(token.as_deref()).await
260    })
261    .await?;
262    // Resolve the host profile: explicit --profile wins; otherwise --takeover
263    // derives the URL's registrable domain (eTLD+1) for per-domain isolation.
264    let explicit_profile = args.profile.clone();
265    let resolved_profile: Option<String> = if let Some(p) = explicit_profile.clone() {
266        Some(p)
267    } else if args.takeover {
268        Some(default_profile_for_url(&args.url)?)
269    } else {
270        None
271    };
272    if resolved_profile.is_some() && args.endpoint.is_none() {
273        return Err(Error::new(
274            ErrorCode::InvalidArgument,
275            "--profile (and --takeover profile derivation) switch the host's active profile and require a host; pass --endpoint-url or set AFHTTP_ENDPOINT_URL",
276        )
277        .into());
278    }
279    let takeover = args.takeover;
280    let takeover_endpoint = args.endpoint.clone();
281    let recommended_endpoint =
282        takeover_recommended_endpoint(takeover_endpoint.as_deref(), endpoint_was_preconfigured);
283    let takeover_token = args.token.clone();
284    let wait = Wait::parse(&args.wait)?;
285    let timeout = Duration::from_millis(args.timeout_ms);
286    let network_bodies = NetworkBodies::from(args.network_bodies);
287    let network_redact = !args.no_network_redact;
288
289    let body_bytes = resolve_body(&args).await?;
290    let want = resolve_want(&args.want)?;
291    let mut client = build_client(&args, render).await?;
292    if let Some(profile) = &resolved_profile {
293        client = client.with_profile(profile.clone());
294    }
295
296    let mut builder = client
297        .fetch(args.url.clone())
298        .render(render)
299        .wait(wait)
300        .timeout(timeout)
301        .network_bodies(network_bodies)
302        .network_body_max_bytes(args.network_body_max_bytes)
303        .readiness_idle_ms(args.readiness_idle_ms)
304        .readiness_stable_ms(args.readiness_stable_ms)
305        .readiness_min_text_bytes(args.readiness_min_text_bytes)
306        .network_redact(network_redact)
307        .method(args.method);
308    if let Some(want) = want {
309        builder = builder.want(want);
310    }
311    if let Some(bytes) = body_bytes {
312        builder = builder.body(bytes);
313    }
314    for raw in &args.form {
315        let (k, v) = raw.split_once('=').ok_or_else(|| {
316            Error::new(
317                ErrorCode::InvalidArgument,
318                format!("--form: expected key=value, got {raw:?}"),
319            )
320        })?;
321        builder = builder.form_field(k, v);
322    }
323    for raw in args.headers {
324        let (name, value) = parse_header_arg(&raw)?;
325        builder = builder.header(name, value);
326    }
327    for raw in args.cookies {
328        builder = builder.cookie_full(parse_cookie_arg(&raw)?);
329    }
330    if let Some(user_agent) = args.user_agent {
331        builder = builder.user_agent(user_agent);
332    }
333    for js in args.evaluate_after_wait {
334        builder = builder.evaluate_after_wait(js);
335    }
336    if args.tab != "new" {
337        builder = builder.tab(TabId::new(args.tab));
338    }
339    if takeover {
340        // Keep the prepared tab open so a human can take it over.
341        builder = builder.keep_tab_open(true);
342    }
343    if let Some(out) = args.out {
344        builder = builder.out_dir(out);
345    }
346    builder = builder.observe_main_wait_ms(args.observe_main_wait_ms);
347    builder = builder.max_response_bytes(args.max_response_bytes);
348    builder = builder.retry(args.retry).backoff_ms(args.backoff_ms);
349    if let Some(url) = args.proxy {
350        builder = builder.proxy(url);
351    }
352    if let Some(path) = args.ca_cert {
353        builder = builder.ca_cert(path);
354    }
355    if args.tls_insecure {
356        builder = builder.tls_insecure(true);
357    }
358    if args.capture_ws {
359        builder = builder.capture_ws(true);
360    }
361    if args.capture_sse {
362        builder = builder.capture_sse(true);
363    }
364    if args.no_cookie_jar {
365        builder = builder.no_cookie_jar();
366    } else {
367        let cookie_jar = args.cookie_jar.or_else(|| {
368            std::env::var_os("AFHTTP_COOKIE_JAR")
369                .filter(|v| !v.is_empty())
370                .map(PathBuf::from)
371        });
372        if let Some(jar) = cookie_jar {
373            builder = builder.cookie_jar(jar);
374        }
375    }
376
377    match builder.send_detailed().await {
378        Ok(mut result) => {
379            if takeover && result.next_action.is_some() {
380                if let Some(endpoint) = takeover_endpoint.as_deref() {
381                    let mut handoff_client = Client::connect(endpoint)?;
382                    if let Some(token) = takeover_token.as_deref() {
383                        handoff_client = handoff_client.with_token(token);
384                    }
385                    let tab_id = result.tab_id.as_ref().map(|t| t.as_str().to_string());
386                    let handoff = handoff_client
387                        .takeover_handoff(None, tab_id.as_deref())
388                        .await?;
389                    result.attach_takeover_with_context(
390                        handoff.takeover_url,
391                        Some(handoff.takeover_url_expires_at_rfc3339),
392                        Some(handoff.takeover_url_ttl_s),
393                        Some(handoff.takeover_url_scope),
394                        recommended_endpoint,
395                        explicit_profile.as_deref(),
396                    );
397                }
398            }
399            Ok(output::emit("fetch", &result)?)
400        }
401        Err(err) => {
402            output::emit("error", &err)?;
403            Err(FetchRunError::Emitted(err.into_error()))
404        }
405    }
406}
407
408async fn prepare_takeover_connection<D, Fut>(
409    args: &mut Args,
410    render: RenderMode,
411    discover: D,
412) -> Result<(), Error>
413where
414    D: FnOnce(Option<String>) -> Fut,
415    Fut: std::future::Future<Output = Result<crate::cli::cmd::container::LocalTakeoverHost, Error>>,
416{
417    if !args.takeover {
418        return Ok(());
419    }
420    if matches!(render, RenderMode::None) {
421        return Err(Error::new(
422            ErrorCode::InvalidArgument,
423            "fetch --takeover needs a browser render; use --render auto or always",
424        ));
425    }
426    if args.endpoint.is_none() {
427        let discovered = discover(args.token.clone()).await?;
428        args.endpoint = Some(discovered.endpoint);
429        if args.token.is_none() {
430            args.token = discovered.token_secret;
431        }
432    }
433    Ok(())
434}
435
436fn takeover_recommended_endpoint(
437    endpoint: Option<&str>,
438    endpoint_was_preconfigured: bool,
439) -> Option<&str> {
440    if endpoint_was_preconfigured {
441        endpoint
442    } else {
443        None
444    }
445}
446
447/// Derive a default host profile from a URL's registrable domain (eTLD+1), so
448/// `fetch --takeover` gives each site its own isolated browser profile when
449/// `--profile` is omitted. Falls back to the full host for public-suffix
450/// tenants (e.g. `foo.github.io`) and bare IPs.
451fn default_profile_for_url(raw_url: &str) -> Result<String, Error> {
452    let parsed = url::Url::parse(raw_url).map_err(|e| {
453        Error::new(
454            ErrorCode::InvalidArgument,
455            format!(
456                "--takeover default profile needs a valid URL with a host; \
457                 could not parse {raw_url:?}: {e}; pass --profile <name>"
458            ),
459        )
460    })?;
461    let host = parsed.host().ok_or_else(|| {
462        Error::new(
463            ErrorCode::InvalidArgument,
464            format!(
465                "--takeover default profile needs URL {raw_url:?} to include a host; \
466                 pass --profile <name>"
467            ),
468        )
469    })?;
470    let (normalized_host, dns_name) = match host {
471        url::Host::Domain(domain) => (normalize_profile_host(domain), true),
472        url::Host::Ipv4(addr) => (addr.to_string(), false),
473        url::Host::Ipv6(addr) => (addr.to_string(), false),
474    };
475    let profile = if dns_name {
476        psl::domain_str(&normalized_host)
477            .unwrap_or(&normalized_host)
478            .to_string()
479    } else {
480        normalized_host.clone()
481    };
482    crate::sdk::profile::paths::validate_name(&profile).map_err(|e| {
483        Error::new(
484            e.error_code,
485            format!(
486                "derived --takeover profile {profile:?} from URL host {normalized_host:?} \
487                 is invalid: {}; pass --profile <name>",
488                e.detail
489            ),
490        )
491    })?;
492    Ok(profile)
493}
494
495fn normalize_profile_host(host: &str) -> String {
496    host.trim_end_matches('.').to_ascii_lowercase()
497}
498
499enum FetchRunError {
500    Plain(Error),
501    Emitted(Error),
502}
503
504impl From<Error> for FetchRunError {
505    fn from(err: Error) -> Self {
506        Self::Plain(err)
507    }
508}
509
510/// Resolve the request body from `--data` (literal, or `@path` to read a file),
511/// rejecting coexistence with `--form`. `--form` fields are wired separately by
512/// the caller; this only enforces that they don't coexist with a raw body.
513async fn resolve_body(args: &Args) -> Result<Option<Vec<u8>>, Error> {
514    if args.data.is_some() && !args.form.is_empty() {
515        return Err(Error::new(
516            ErrorCode::InvalidArgument,
517            "--data and --form are mutually exclusive",
518        ));
519    }
520    if let Some(data) = &args.data {
521        if let Some(path) = data.strip_prefix('@') {
522            return Ok(Some(tokio::fs::read(path).await.map_err(|e| {
523                Error::new(ErrorCode::IoError, format!("--data @{path}: {e}"))
524            })?));
525        }
526        return Ok(Some(data.as_bytes().to_vec()));
527    }
528    Ok(None)
529}
530
531/// Parse the `--want` tokens into an artifact set; `None` means use the
532/// render-mode-aware default.
533fn resolve_want(want: &[String]) -> Result<Option<std::collections::BTreeSet<Artifact>>, Error> {
534    if want.is_empty() {
535        return Ok(None);
536    }
537    want.iter()
538        .map(|t| parse_artifact(t))
539        .collect::<Result<std::collections::BTreeSet<_>, _>>()
540        .map(Some)
541}
542
543/// Construct the SDK client for this fetch: a remote connection when
544/// `--endpoint-url` is set, the HTTP-only client for `--render none`, or an
545/// inline ephemeral host otherwise (lazy for `auto`, eager for `always`).
546async fn build_client(args: &Args, render: RenderMode) -> Result<Client, Error> {
547    match args.endpoint.as_deref() {
548        Some(ep) => {
549            let mut c = Client::connect(ep)?;
550            if let Some(t) = args.token.as_deref() {
551                c = c.with_token(t);
552            }
553            Ok(c)
554        }
555        None if matches!(render, RenderMode::None) => Client::http_only(),
556        None => {
557            let cfg = InlineConfig {
558                browser: args.browser.into(),
559                browser_bin: args.browser_bin.clone(),
560            };
561            if matches!(render, RenderMode::Auto) {
562                Client::inline_ephemeral_lazy(cfg).await
563            } else {
564                Client::inline_ephemeral_with(cfg).await
565            }
566        }
567    }
568}
569
570fn parse_artifact(token: &str) -> Result<Artifact, Error> {
571    Ok(match token {
572        "body" => Artifact::Body,
573        "rendered_html" => Artifact::RenderedHtml,
574        "text" => Artifact::Text,
575        "content" => Artifact::Content,
576        "content_json" => Artifact::ContentJson,
577        "screenshot" => Artifact::Screenshot,
578        "network" => Artifact::Network,
579        "console" => Artifact::Console,
580        "observation" => Artifact::Observation,
581        "storage" => Artifact::Storage,
582        other => {
583            return Err(Error::new(
584                ErrorCode::InvalidArgument,
585                format!("--want: unknown artifact {other:?}"),
586            ));
587        }
588    })
589}
590
591fn parse_header_arg(raw: &str) -> Result<(String, String), Error> {
592    let (name, value) = raw.split_once(':').ok_or_else(|| {
593        Error::new(
594            ErrorCode::InvalidArgument,
595            format!("--header: expected K:V, got {raw:?}"),
596        )
597    })?;
598    let name = name.trim();
599    if name.is_empty() {
600        return Err(Error::new(
601            ErrorCode::InvalidArgument,
602            format!("--header: header name must not be empty in {raw:?}"),
603        ));
604    }
605    Ok((name.to_string(), value.trim_start().to_string()))
606}
607
608fn parse_cookie_arg(raw: &str) -> Result<FetchCookie, Error> {
609    if !raw.contains('=') {
610        return Err(Error::new(
611            ErrorCode::InvalidArgument,
612            format!("--cookie: expected Set-Cookie style name=value, got {raw:?}"),
613        ));
614    }
615    let cookie = FetchCookie::parse(raw.to_string())
616        .map_err(|e| Error::new(ErrorCode::InvalidArgument, format!("--cookie: {e}")))?
617        .into_owned();
618    if cookie.name().trim().is_empty() {
619        return Err(Error::new(
620            ErrorCode::InvalidArgument,
621            format!("--cookie: cookie name must not be empty in {raw:?}"),
622        ));
623    }
624    Ok(cookie)
625}
626
627#[cfg(test)]
628mod tests {
629    use super::*;
630
631    fn base_args(url: &str) -> Args {
632        Args {
633            url: url.to_string(),
634            endpoint: None,
635            token: None,
636            browser: BrowserArg::Auto,
637            browser_bin: None,
638            render: RenderArg::Auto,
639            tab: "new".into(),
640            takeover: false,
641            profile: None,
642            wait: "auto".into(),
643            headers: Vec::new(),
644            cookies: Vec::new(),
645            user_agent: None,
646            evaluate_after_wait: Vec::new(),
647            want: Vec::new(),
648            method: "GET".into(),
649            data: None,
650            form: Vec::new(),
651            network_bodies: NetworkBodiesArg::Off,
652            network_body_max_bytes: DEFAULT_NETWORK_BODY_MAX_BYTES,
653            readiness_idle_ms: 800,
654            readiness_stable_ms: 500,
655            readiness_min_text_bytes: 32,
656            no_network_redact: false,
657            out: None,
658            cookie_jar: None,
659            no_cookie_jar: false,
660            observe_main_wait_ms: 500,
661            max_response_bytes: 1_073_741_824,
662            retry: 0,
663            backoff_ms: 250,
664            proxy: None,
665            ca_cert: None,
666            tls_insecure: false,
667            timeout_ms: 30_000,
668            capture_ws: false,
669            capture_sse: false,
670        }
671    }
672
673    #[tokio::test]
674    async fn takeover_autodiscovery_fills_missing_endpoint_and_token() {
675        let mut args = base_args("https://contabo.com");
676        args.takeover = true;
677        prepare_takeover_connection(&mut args, RenderMode::Auto, |token| async move {
678            assert_eq!(token, None);
679            Ok(crate::cli::cmd::container::LocalTakeoverHost {
680                endpoint: "ws://127.0.0.1:9222".into(),
681                token_secret: Some("secret".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("secret"));
689    }
690
691    #[tokio::test]
692    async fn takeover_autodiscovery_preserves_existing_token() {
693        let mut args = base_args("https://contabo.com");
694        args.takeover = true;
695        args.token = Some("env-token".into());
696        prepare_takeover_connection(&mut args, RenderMode::Auto, |token| async move {
697            assert_eq!(token.as_deref(), Some("env-token"));
698            Ok(crate::cli::cmd::container::LocalTakeoverHost {
699                endpoint: "ws://127.0.0.1:9222".into(),
700                token_secret: Some("container-token".into()),
701            })
702        })
703        .await
704        .unwrap();
705
706        assert_eq!(args.endpoint.as_deref(), Some("ws://127.0.0.1:9222"));
707        assert_eq!(args.token.as_deref(), Some("env-token"));
708    }
709
710    #[tokio::test]
711    async fn takeover_autodiscovery_surfaces_failure() {
712        let mut args = base_args("https://contabo.com");
713        args.takeover = true;
714        let err = prepare_takeover_connection(&mut args, RenderMode::Auto, |_| async {
715            Err(Error::new(
716                ErrorCode::InvalidArgument,
717                "default local container `afhttp-host` is not running",
718            ))
719        })
720        .await
721        .err()
722        .unwrap();
723
724        assert_eq!(err.error_code, ErrorCode::InvalidArgument);
725        assert!(err.detail.contains("afhttp-host"));
726        assert!(args.endpoint.is_none());
727    }
728
729    #[tokio::test]
730    async fn takeover_autodiscovery_rejects_render_none() {
731        let mut args = base_args("https://contabo.com");
732        args.takeover = true;
733        let err = prepare_takeover_connection(&mut args, RenderMode::None, |_| async {
734            Ok(crate::cli::cmd::container::LocalTakeoverHost {
735                endpoint: "ws://127.0.0.1:9222".into(),
736                token_secret: None,
737            })
738        })
739        .await
740        .err()
741        .unwrap();
742
743        assert_eq!(err.error_code, ErrorCode::InvalidArgument);
744        assert!(err.detail.contains("browser render"));
745    }
746
747    #[test]
748    fn takeover_recommendation_omits_auto_discovered_endpoint() {
749        assert_eq!(
750            takeover_recommended_endpoint(Some("ws://127.0.0.1:9222"), false),
751            None
752        );
753    }
754
755    #[test]
756    fn takeover_recommendation_keeps_preconfigured_endpoint() {
757        assert_eq!(
758            takeover_recommended_endpoint(Some("ws://127.0.0.1:9222"), true),
759            Some("ws://127.0.0.1:9222")
760        );
761    }
762
763    #[test]
764    fn default_profile_uses_registrable_domain() {
765        assert_eq!(
766            default_profile_for_url("https://www.court.gov.cn/foo").unwrap(),
767            "court.gov.cn"
768        );
769        assert_eq!(
770            default_profile_for_url("https://accounts.google.com/foo").unwrap(),
771            "google.com"
772        );
773        assert_eq!(
774            default_profile_for_url("https://contabo.com").unwrap(),
775            "contabo.com"
776        );
777    }
778
779    #[test]
780    fn default_profile_keeps_public_suffix_tenants_isolated() {
781        assert_eq!(
782            default_profile_for_url("https://foo.github.io/x").unwrap(),
783            "foo.github.io"
784        );
785        assert_eq!(
786            default_profile_for_url("https://tenant.vercel.app/x").unwrap(),
787            "tenant.vercel.app"
788        );
789    }
790
791    #[test]
792    fn default_profile_normalizes_case_and_trailing_dot() {
793        assert_eq!(
794            default_profile_for_url("https://WWW.Example.COM./foo").unwrap(),
795            "example.com"
796        );
797    }
798
799    #[test]
800    fn default_profile_falls_back_to_full_host_for_psl_misses_and_ips() {
801        assert_eq!(
802            default_profile_for_url("http://localhost:8080/foo").unwrap(),
803            "localhost"
804        );
805        assert_eq!(
806            default_profile_for_url("http://127.0.0.1:8080/foo").unwrap(),
807            "127.0.0.1"
808        );
809    }
810
811    #[test]
812    fn default_profile_errors_when_host_is_missing() {
813        assert!(default_profile_for_url("file:///tmp/page.html").is_err());
814    }
815
816    #[test]
817    fn header_arg_accepts_colon_separator() {
818        assert_eq!(
819            parse_header_arg("X-Test: yes").unwrap(),
820            ("X-Test".to_string(), "yes".to_string())
821        );
822    }
823
824    #[test]
825    fn header_arg_rejects_missing_colon() {
826        let err = parse_header_arg("X-Test").err().unwrap();
827        assert_eq!(err.error_code, ErrorCode::InvalidArgument);
828    }
829
830    #[test]
831    fn cookie_arg_accepts_equals_separator() {
832        let cookie = parse_cookie_arg("sid=abc=def").unwrap();
833        assert_eq!(cookie.name_value(), ("sid", "abc=def"));
834    }
835
836    #[test]
837    fn cookie_arg_accepts_full_set_cookie_attributes() {
838        let cookie = parse_cookie_arg("sid=abc; Path=/; Secure; HttpOnly; SameSite=Lax").unwrap();
839        assert_eq!(cookie.name_value(), ("sid", "abc"));
840        assert_eq!(cookie.path(), Some("/"));
841        assert_eq!(cookie.secure(), Some(true));
842        assert_eq!(cookie.http_only(), Some(true));
843        assert_eq!(cookie.same_site(), Some(cookie::SameSite::Lax));
844    }
845
846    #[test]
847    fn cookie_arg_rejects_missing_equals() {
848        let err = parse_cookie_arg("sid").err().unwrap();
849        assert_eq!(err.error_code, ErrorCode::InvalidArgument);
850    }
851}