Skip to main content

bobby_browser_client/
commands.rs

1//! Command envelopes, primitives, intents, and session create/open requests.
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5use std::collections::{BTreeMap, BTreeSet};
6
7use crate::{
8    AttemptId, CommandId, FormControlTarget, PageId, SessionId, WorkflowId, MAX_FORM_REFERENCES,
9    MAX_FORM_VALUE_BYTES,
10};
11
12/// Maximum UTF-8 byte length for intent `purpose` strings.
13pub const MAX_INTENT_PURPOSE_BYTES: usize = 256;
14
15/// Envelope submitted to `POST /v1/commands`.
16#[derive(Debug, Clone, Serialize, Deserialize)]
17#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
18#[serde(rename_all = "camelCase")]
19pub struct CommandEnvelope {
20    pub schema_version: u16,
21    pub command_id: CommandId,
22    pub workflow_id: WorkflowId,
23    pub attempt_id: AttemptId,
24    pub session_id: SessionId,
25    pub page_id: Option<PageId>,
26    pub deadline: DateTime<Utc>,
27    pub command: RuntimeCommand,
28}
29
30impl CommandEnvelope {
31    pub const SCHEMA_VERSION: u16 = 2;
32
33    /// Returns a copy safe for durable journals (URLs sanitized).
34    /// Keep the live envelope in memory for execution.
35    pub fn journal_safe(&self) -> Self {
36        let mut safe = self.clone();
37        safe.command.sanitize_urls();
38        safe
39    }
40}
41
42/// Nested command wire shape: `{ kind: "intent" | "primitive", input: … }`.
43#[derive(Debug, Clone, Serialize, Deserialize)]
44#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
45#[serde(tag = "kind", content = "input", rename_all = "camelCase")]
46pub enum RuntimeCommand {
47    Primitive(PrimitiveCommand),
48    Intent(IntentCommand),
49}
50
51impl RuntimeCommand {
52    pub fn class(&self) -> CommandClass {
53        match self {
54            Self::Primitive(command) => command.class(),
55            Self::Intent(command) => command.class(),
56        }
57    }
58
59    pub fn sanitize_urls(&mut self) {
60        match self {
61            Self::Primitive(command) => command.sanitize_urls(),
62            Self::Intent(_) => {}
63        }
64    }
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize)]
68#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
69#[serde(tag = "kind", content = "input", rename_all = "camelCase")]
70pub enum IntentCommand {
71    Locate(LocateIntent),
72    Fill(FillIntent),
73    CompleteForm(CompleteFormIntent),
74    SubmitAndVerify(SubmitAndVerifyIntent),
75    WaitForState(WaitForStateIntent),
76    Follow(FollowIntent),
77    DismissObstruction(DismissObstructionIntent),
78    Extract(ExtractIntent),
79    /// Vision-primary challenge solving (captchas, verification widgets).
80    /// Bypasses DOM resolution: the engine loops screenshot → vision proposal
81    /// → action until the model reports the challenge solved or the hint
82    /// timeout elapses.
83    SolveChallenge(crate::challenges::SolveChallengeIntent),
84    /// Read-only challenge classification: screenshot → vision classify →
85    /// report. Never acts on the page, so it is replayable like Locate.
86    DetectChallenge(crate::challenges::DetectChallengeIntent),
87}
88
89impl IntentCommand {
90    pub fn class(&self) -> CommandClass {
91        match self {
92            Self::Locate(_) | Self::WaitForState(_) | Self::Extract(_) => CommandClass::Replayable,
93            Self::DetectChallenge(_) => CommandClass::Replayable,
94            Self::Fill(_) | Self::CompleteForm(_) | Self::DismissObstruction(_) => {
95                CommandClass::Reconciliable
96            }
97            Self::SolveChallenge(_) => CommandClass::Reconciliable,
98            Self::SubmitAndVerify(_) => CommandClass::Boundary,
99            Self::Follow(intent) => {
100                if intent.boundary {
101                    CommandClass::Boundary
102                } else {
103                    CommandClass::Reconciliable
104                }
105            }
106        }
107    }
108}
109
110/// Optional targeting hints for intents. All fields are optional on the wire
111/// (`#[serde(default)]`); a body like `{"role":"textbox"}` must deserialize.
112#[derive(Debug, Clone, Default, Serialize, Deserialize)]
113#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
114#[serde(rename_all = "camelCase", default)]
115pub struct IntentHints {
116    pub role: Option<String>,
117    pub near_text: Option<TextMatch>,
118    /// Accessible name of the control, matched exactly. Accepts an
119    /// `a11y_snapshot` node's `target` verbatim, which carries
120    /// `{role, accessibleName, ordinal}` -- before this existed the field was
121    /// dropped silently and the intent resolved on role and ordinal alone.
122    /// Equivalent to `near_text: Exact`; setting both to different values is
123    /// refused rather than resolved.
124    pub accessible_name: Option<String>,
125    pub ordinal: Option<usize>,
126    pub frame_path: Vec<TargetSpec>,
127    pub shadow_path: Vec<TargetSpec>,
128    pub allow_best_match: bool,
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize)]
132#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
133#[serde(rename_all = "camelCase")]
134pub struct LocateIntent {
135    pub purpose: String,
136    #[serde(default)]
137    pub hints: IntentHints,
138}
139
140#[derive(Debug, Clone, Serialize, Deserialize)]
141#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
142#[serde(rename_all = "camelCase")]
143pub struct FillIntent {
144    pub purpose: String,
145    #[serde(default)]
146    pub hints: IntentHints,
147    pub value: ControlAction,
148}
149
150#[derive(Debug, Clone, Serialize, Deserialize)]
151#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
152#[serde(rename_all = "camelCase")]
153pub struct CompleteFormField {
154    pub name: String,
155    pub purpose: String,
156    #[serde(default)]
157    pub hints: IntentHints,
158    pub value: ControlAction,
159}
160
161#[derive(Debug, Clone, Serialize, Deserialize)]
162#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
163#[serde(rename_all = "camelCase")]
164pub struct CompleteFormIntent {
165    pub purpose: String,
166    pub fields: Vec<CompleteFormField>,
167}
168
169#[derive(Debug, Clone, Serialize, Deserialize)]
170#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
171#[serde(rename_all = "camelCase")]
172pub struct SubmitAndVerifyIntent {
173    pub purpose: String,
174    #[serde(default)]
175    pub hints: IntentHints,
176    pub expected_state: WaitForCommand,
177}
178
179#[derive(Debug, Clone, Serialize, Deserialize)]
180#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
181#[serde(rename_all = "camelCase")]
182pub struct WaitForStateIntent {
183    pub condition: WaitCondition,
184    pub timeout_ms: u64,
185}
186
187/// Follow a control and wait for the expected destination.
188///
189/// Set `boundary` when activation may mutate state or trigger a side effect
190/// (same meaning as [`ClickCommand::boundary`]).
191#[derive(Debug, Clone, Serialize, Deserialize)]
192#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
193#[serde(rename_all = "camelCase")]
194pub struct FollowIntent {
195    pub purpose: String,
196    #[serde(default)]
197    pub hints: IntentHints,
198    pub expected_destination: WaitForCommand,
199    #[serde(default)]
200    pub boundary: bool,
201}
202
203pub const DEFAULT_DISMISS_OBSTRUCTION_TIMEOUT_MS: u64 = 5_000;
204
205fn default_dismiss_timeout_ms() -> u64 {
206    DEFAULT_DISMISS_OBSTRUCTION_TIMEOUT_MS
207}
208
209/// Dismiss an obstruction (overlay, cookie banner, etc.).
210///
211/// Always reconciliable. Verification is built in: after acting, the same
212/// target must be detached or hidden.
213#[derive(Debug, Clone, Serialize, Deserialize)]
214#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
215#[serde(rename_all = "camelCase")]
216pub struct DismissObstructionIntent {
217    pub purpose: String,
218    #[serde(default)]
219    pub hints: IntentHints,
220    #[serde(default = "default_dismiss_timeout_ms")]
221    pub timeout_ms: u64,
222}
223
224/// Value to extract from a resolved field. `Href` is shorthand for
225/// `attribute = "href"`; other attributes use [`ExtractValueKind::Attribute`].
226#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
227#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
228#[serde(tag = "kind", rename_all = "camelCase")]
229pub enum ExtractValueKind {
230    #[default]
231    Text,
232    Attribute {
233        attribute: String,
234    },
235    Href,
236}
237
238/// One named field within an [`ExtractIntent`], resolved independently of siblings.
239#[derive(Debug, Clone, Serialize, Deserialize)]
240#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
241#[serde(rename_all = "camelCase")]
242pub struct ExtractField {
243    pub name: String,
244    pub purpose: String,
245    #[serde(default)]
246    pub hints: IntentHints,
247    #[serde(default)]
248    pub value: ExtractValueKind,
249}
250
251/// Structured extraction intent. Replayable (does not mutate the page).
252///
253/// Fields resolve independently: a missing field is reported in that field's
254/// evidence rather than failing the whole command.
255#[derive(Debug, Clone, Serialize, Deserialize)]
256#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
257#[serde(rename_all = "camelCase")]
258pub struct ExtractIntent {
259    pub purpose: String,
260    pub fields: Vec<ExtractField>,
261}
262
263#[derive(Debug, Clone, Serialize, Deserialize)]
264#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
265#[serde(tag = "kind", content = "input", rename_all = "camelCase")]
266pub enum PrimitiveCommand {
267    Navigate(NavigateCommand),
268    DownloadUrl(DownloadUrlCommand),
269    Inspect(InspectCommand),
270    Click(ClickCommand),
271    TypeText(TypeTextCommand),
272    UploadFiles(UploadFilesCommand),
273    OpenPage(OpenPageCommand),
274    ListPages(ListPagesCommand),
275    ClosePage(ClosePageCommand),
276    ActivatePage(ActivatePageCommand),
277    AccessibilitySnapshot(AccessibilitySnapshotCommand),
278    ExtractStructured(ExtractStructuredCommand),
279    GetCookies(GetCookiesCommand),
280    PrintToPdf(PrintToPdfCommand),
281    HandleDialog(HandleDialogCommand),
282    Emulate(EmulateCommand),
283    NetworkLog(NetworkLogCommand),
284    SetCookies(SetCookiesCommand),
285    DeleteCookies(DeleteCookiesCommand),
286    ClickAndWaitForPopup(ClickAndWaitForPopupCommand),
287    ClickAndWaitForDownload(ClickAndWaitForDownloadCommand),
288    WaitFor(WaitForCommand),
289    CaptureScreenshot(CaptureScreenshotCommand),
290    ControlAction(ControlActionCommand),
291    SetFocusEmulation(SetFocusEmulationCommand),
292    SetEmulatedMedia(SetEmulatedMediaCommand),
293    EvaluateJavaScript(EvaluateJavaScriptCommand),
294}
295
296impl PrimitiveCommand {
297    fn sanitize_urls(&mut self) {
298        fn sanitize(value: &mut String) {
299            let Ok(mut url) = url::Url::parse(value) else {
300                *value = "[redacted-invalid-url]".into();
301                return;
302            };
303            let _ = url.set_username("");
304            let _ = url.set_password(None);
305            url.set_query(None);
306            url.set_fragment(None);
307            *value = url.to_string();
308        }
309        match self {
310            Self::Navigate(command) => sanitize(&mut command.url),
311            Self::DownloadUrl(command) => {
312                sanitize(&mut command.url);
313                if let Some(save_as) = &mut command.save_as {
314                    *save_as = "[redacted-download-path]".into();
315                }
316            }
317            Self::UploadFiles(command) => {
318                for (index, path) in command.paths.iter_mut().enumerate() {
319                    *path = format!("upload://input/{index}");
320                }
321            }
322            Self::OpenPage(command) => {
323                if let Some(url) = &mut command.url {
324                    sanitize(url);
325                }
326            }
327            Self::Click(command) => {
328                if let Some(url) = &mut command.expected_url {
329                    sanitize(url);
330                }
331            }
332            Self::ControlAction(command) => {
333                if let ControlAction::SetFiles { paths } = &mut command.action {
334                    for (index, path) in paths.iter_mut().enumerate() {
335                        *path = format!("upload://input/{index}");
336                    }
337                }
338            }
339            _ => {}
340        }
341    }
342
343    pub fn class(&self) -> CommandClass {
344        match self {
345            Self::Navigate(_)
346            | Self::Inspect(_)
347            | Self::OpenPage(_)
348            | Self::ListPages(_)
349            | Self::WaitFor(_)
350            | Self::ActivatePage(_)
351            | Self::Emulate(_)
352            | Self::NetworkLog(_)
353            | Self::AccessibilitySnapshot(_)
354            | Self::ExtractStructured(_)
355            | Self::GetCookies(_)
356            | Self::PrintToPdf(_)
357            | Self::CaptureScreenshot(_) => CommandClass::Replayable,
358            Self::SetCookies(_) | Self::DeleteCookies(_) | Self::HandleDialog(_) => {
359                CommandClass::Reconciliable
360            }
361            Self::DownloadUrl(_)
362            | Self::TypeText(_)
363            | Self::UploadFiles(_)
364            | Self::ClosePage(_)
365            | Self::EvaluateJavaScript(_) => CommandClass::Reconciliable,
366            Self::ControlAction(_) => CommandClass::Reconciliable,
367            Self::ClickAndWaitForPopup(_) | Self::ClickAndWaitForDownload(_) => {
368                CommandClass::Boundary
369            }
370            Self::Click(command) if command.boundary => CommandClass::Boundary,
371            Self::Click(_) => CommandClass::Reconciliable,
372            Self::SetFocusEmulation(_) => CommandClass::Reconciliable,
373            Self::SetEmulatedMedia(_) => CommandClass::Reconciliable,
374        }
375    }
376}
377
378#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
379#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
380#[serde(rename_all = "camelCase", deny_unknown_fields)]
381pub struct ControlActionCommand {
382    pub target: FormControlTarget,
383    pub action: ControlAction,
384}
385
386fn default_clear_first() -> bool {
387    true
388}
389
390#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
391#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
392#[serde(tag = "kind", rename_all = "camelCase", deny_unknown_fields)]
393pub enum ControlAction {
394    SetText {
395        value: String,
396        #[serde(default = "default_clear_first", rename = "clearFirst")]
397        clear_first: bool,
398    },
399    SetChecked {
400        checked: bool,
401    },
402    SelectOne {
403        value: String,
404    },
405    SelectMany {
406        values: Vec<String>,
407    },
408    SetFiles {
409        paths: Vec<String>,
410    },
411    Clear,
412    Activate,
413}
414
415impl ControlAction {
416    pub fn validate(&self) -> Result<(), String> {
417        fn bounded(value: &str, field: &str) -> Result<(), String> {
418            if value.len() > MAX_FORM_VALUE_BYTES {
419                return Err(format!("{field} exceeds {MAX_FORM_VALUE_BYTES} bytes"));
420            }
421            Ok(())
422        }
423
424        match self {
425            Self::SetText { value, .. } | Self::SelectOne { value } => bounded(value, "value"),
426            Self::SelectMany { values } => {
427                if values.is_empty() || values.len() > MAX_FORM_REFERENCES {
428                    return Err(format!(
429                        "values must contain between 1 and {MAX_FORM_REFERENCES} items"
430                    ));
431                }
432                let mut unique = BTreeSet::new();
433                for value in values {
434                    bounded(value, "selection value")?;
435                    if !unique.insert(value) {
436                        return Err("selection values must be unique".into());
437                    }
438                }
439                Ok(())
440            }
441            Self::SetFiles { paths } => {
442                if paths.is_empty() || paths.len() > MAX_FORM_REFERENCES {
443                    return Err(format!(
444                        "paths must contain between 1 and {MAX_FORM_REFERENCES} items"
445                    ));
446                }
447                for path in paths {
448                    bounded(path, "file path")?;
449                }
450                Ok(())
451            }
452            Self::SetChecked { .. } | Self::Clear | Self::Activate => Ok(()),
453        }
454    }
455
456    pub fn operation(&self) -> crate::FormControlOperation {
457        match self {
458            Self::SetText { .. } => crate::FormControlOperation::SetText,
459            Self::SetChecked { .. } => crate::FormControlOperation::SetChecked,
460            Self::SelectOne { .. } => crate::FormControlOperation::SelectOne,
461            Self::SelectMany { .. } => crate::FormControlOperation::SelectMany,
462            Self::SetFiles { .. } => crate::FormControlOperation::SetFiles,
463            Self::Clear => crate::FormControlOperation::Clear,
464            Self::Activate => crate::FormControlOperation::Activate,
465        }
466    }
467}
468
469#[derive(Debug, Clone, Serialize, Deserialize)]
470#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
471#[serde(rename_all = "camelCase")]
472pub struct DownloadUrlCommand {
473    pub url: String,
474    pub expected_content_type: Option<String>,
475    pub max_bytes: u64,
476    pub save_as: Option<String>,
477}
478
479#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
480#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
481#[serde(rename_all = "camelCase")]
482pub enum CommandClass {
483    Replayable,
484    Reconciliable,
485    Boundary,
486}
487
488#[derive(Debug, Clone, Serialize, Deserialize)]
489#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
490#[serde(rename_all = "camelCase")]
491pub struct NavigateCommand {
492    pub url: String,
493    pub wait_until: WaitUntil,
494    pub timeout_ms: u64,
495}
496
497#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
498#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
499#[serde(rename_all = "camelCase")]
500pub enum WaitUntil {
501    Commit,
502    DomContentLoaded,
503    Interactive,
504    NetworkIdle,
505}
506
507#[derive(Debug, Clone, Default, Serialize, Deserialize)]
508#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
509#[serde(rename_all = "camelCase")]
510pub struct InspectCommand {
511    pub selector: Option<String>,
512    pub target: Option<TargetSpec>,
513    pub include_html: bool,
514}
515
516#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
517#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
518#[serde(rename_all = "camelCase")]
519pub enum ClickModifier {
520    Shift,
521    Ctrl,
522    Alt,
523    Meta,
524}
525
526#[derive(Debug, Clone, Serialize, Deserialize)]
527#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
528#[serde(rename_all = "camelCase")]
529pub struct ClickCommand {
530    pub selector: String,
531    pub target: Option<TargetSpec>,
532    pub boundary: bool,
533    pub expected_url: Option<String>,
534    #[serde(default, skip_serializing_if = "Vec::is_empty")]
535    pub modifiers: Vec<ClickModifier>,
536}
537
538#[derive(Debug, Clone, Serialize, Deserialize)]
539#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
540#[serde(rename_all = "camelCase")]
541pub struct TypeTextCommand {
542    pub selector: String,
543    pub target: Option<TargetSpec>,
544    pub value: String,
545    pub clear_first: bool,
546    /// When set, fail before typing unless the page URL matches
547    /// (same role as [`ClickCommand::expected_url`]).
548    #[serde(default)]
549    pub expected_url: Option<String>,
550}
551
552#[derive(Debug, Clone, Serialize, Deserialize)]
553#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
554#[serde(rename_all = "camelCase")]
555pub struct UploadFilesCommand {
556    pub selector: String,
557    pub target: Option<TargetSpec>,
558    pub paths: Vec<String>,
559}
560
561#[derive(Debug, Clone, Serialize, Deserialize)]
562#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
563#[serde(rename_all = "camelCase")]
564pub struct OpenPageCommand {
565    pub url: Option<String>,
566}
567
568#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
569#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
570pub struct ListPagesCommand;
571
572#[derive(Debug, Clone, Serialize, Deserialize)]
573#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
574#[serde(rename_all = "camelCase")]
575pub struct ClosePageCommand {
576    pub page_id: PageId,
577}
578
579#[derive(Debug, Clone, Serialize, Deserialize)]
580#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
581#[serde(rename_all = "camelCase")]
582pub struct ActivatePageCommand {
583    pub page_id: PageId,
584}
585
586#[derive(Debug, Clone, Serialize, Deserialize)]
587#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
588#[serde(rename_all = "camelCase")]
589pub struct AccessibilitySnapshotCommand {
590    pub max_nodes: Option<u32>,
591    /// Scope the tree to the subtree rooted at this target (e.g. the form or
592    /// dialog being worked on) instead of paying for the whole page on every
593    /// re-read. Accepts the same shape as `wait_for` targets.
594    #[serde(default)]
595    pub target: Option<TargetSpec>,
596}
597
598#[derive(Debug, Clone, Serialize, Deserialize)]
599#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
600#[serde(rename_all = "camelCase")]
601pub struct ExtractStructuredCommand {
602    pub schema: serde_json::Value,
603    pub purpose: Option<String>,
604}
605
606/// One cookie as returned by a cookie read on any engine.
607#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
608#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
609#[serde(rename_all = "camelCase", deny_unknown_fields)]
610pub struct CookieRecord {
611    pub name: String,
612    pub value: String,
613    pub domain: String,
614    pub path: String,
615    pub secure: bool,
616    pub http_only: bool,
617    #[serde(default, skip_serializing_if = "Option::is_none")]
618    pub same_site: Option<String>,
619    #[serde(default, skip_serializing_if = "Option::is_none")]
620    pub expires_unix: Option<f64>,
621}
622
623/// One cookie to store. `url` anchors the cookie's origin; `path`, flags, and
624/// expiry are optional per the driver defaults.
625#[derive(Debug, Clone, Serialize, Deserialize)]
626#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
627#[serde(rename_all = "camelCase", deny_unknown_fields)]
628pub struct SetCookieParam {
629    pub name: String,
630    pub value: String,
631    pub url: String,
632    #[serde(default)]
633    pub path: Option<String>,
634    #[serde(default)]
635    pub secure: bool,
636    #[serde(default)]
637    pub http_only: bool,
638    #[serde(default)]
639    pub same_site: Option<String>,
640    #[serde(default)]
641    pub expires_unix: Option<f64>,
642}
643
644#[derive(Debug, Clone, Default, Serialize, Deserialize)]
645#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
646#[serde(rename_all = "camelCase", deny_unknown_fields)]
647pub struct NetworkLogCommand {
648    /// Clear the recorded log after producing the artifact (default true).
649    #[serde(default = "default_true")]
650    pub clear: bool,
651}
652
653fn default_true() -> bool {
654    true
655}
656
657#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
658#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
659#[serde(rename_all = "camelCase", deny_unknown_fields)]
660pub struct ViewportSize {
661    pub width: u32,
662    pub height: u32,
663}
664
665#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
666#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
667#[serde(rename_all = "camelCase", deny_unknown_fields)]
668pub struct GeolocationCoordinates {
669    pub latitude: f64,
670    pub longitude: f64,
671    #[serde(default)]
672    pub accuracy: Option<f64>,
673}
674
675#[derive(Debug, Clone, Default, Serialize, Deserialize)]
676#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
677#[serde(rename_all = "camelCase", deny_unknown_fields)]
678pub struct EmulateCommand {
679    #[serde(default)]
680    pub viewport: Option<ViewportSize>,
681    #[serde(default)]
682    pub geolocation: Option<GeolocationCoordinates>,
683    /// Mobile device-metrics flag (Chromium); harmless elsewhere.
684    #[serde(default)]
685    pub mobile: Option<bool>,
686}
687
688#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
689#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
690#[serde(rename_all = "camelCase")]
691pub enum DialogAction {
692    Accept,
693    Dismiss,
694}
695
696#[derive(Debug, Clone, Serialize, Deserialize)]
697#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
698#[serde(rename_all = "camelCase", deny_unknown_fields)]
699pub struct HandleDialogCommand {
700    pub action: DialogAction,
701    #[serde(default)]
702    pub timeout_ms: Option<u64>,
703}
704
705#[derive(Debug, Clone, Serialize, Deserialize)]
706#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
707#[serde(rename_all = "camelCase", deny_unknown_fields)]
708pub struct PrintToPdfCommand {
709    #[serde(default)]
710    pub landscape: bool,
711    #[serde(default = "default_print_background")]
712    pub print_background: bool,
713    #[serde(default)]
714    pub scale: Option<f64>,
715    #[serde(default)]
716    pub page_ranges: Option<String>,
717}
718
719fn default_print_background() -> bool {
720    true
721}
722
723#[derive(Debug, Clone, Default, Serialize, Deserialize)]
724#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
725#[serde(rename_all = "camelCase", deny_unknown_fields)]
726pub struct GetCookiesCommand {
727    /// Restrict to these origin URLs; empty returns all cookies for the page's jar.
728    #[serde(default)]
729    pub urls: Vec<String>,
730}
731
732#[derive(Debug, Clone, Serialize, Deserialize)]
733#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
734#[serde(rename_all = "camelCase", deny_unknown_fields)]
735pub struct SetCookiesCommand {
736    pub cookies: Vec<SetCookieParam>,
737}
738
739#[derive(Debug, Clone, Default, Serialize, Deserialize)]
740#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
741#[serde(rename_all = "camelCase", deny_unknown_fields)]
742pub struct DeleteCookiesCommand {
743    /// Restrict deletion to these origin URLs; empty means every origin.
744    #[serde(default)]
745    pub urls: Vec<String>,
746    /// Restrict deletion to these cookie names; empty means every cookie.
747    #[serde(default)]
748    pub names: Vec<String>,
749}
750
751#[derive(Debug, Clone, Serialize, Deserialize)]
752#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
753#[serde(rename_all = "camelCase")]
754pub struct ClickAndWaitForPopupCommand {
755    pub selector: String,
756    pub target: Option<TargetSpec>,
757    pub timeout_ms: u64,
758}
759
760#[derive(Debug, Clone, Serialize, Deserialize)]
761#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
762#[serde(rename_all = "camelCase")]
763pub struct ClickAndWaitForDownloadCommand {
764    pub selector: String,
765    pub target: Option<TargetSpec>,
766    pub timeout_ms: u64,
767}
768
769#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
770#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
771#[serde(rename_all = "camelCase")]
772pub struct TargetSpec {
773    pub css: Option<String>,
774    pub test_id: Option<String>,
775    pub role: Option<String>,
776    pub accessible_name: Option<String>,
777    pub label: Option<String>,
778    pub text: Option<TextMatch>,
779    #[serde(default)]
780    pub attributes: BTreeMap<String, String>,
781    #[serde(default)]
782    pub frame_path: Vec<Box<TargetSpec>>,
783    #[serde(default)]
784    pub shadow_path: Vec<Box<TargetSpec>>,
785    pub ordinal: Option<usize>,
786    #[serde(default)]
787    pub allow_best_match: bool,
788}
789
790#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
791#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
792#[serde(tag = "kind", content = "value", rename_all = "camelCase")]
793pub enum TextMatch {
794    Exact(String),
795    Contains(String),
796    Regex(String),
797}
798
799#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
800#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
801#[serde(rename_all = "camelCase")]
802pub enum ElementState {
803    Attached,
804    Detached,
805    Visible,
806    Hidden,
807    Enabled,
808    Disabled,
809}
810
811#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
812#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
813#[serde(rename_all = "PascalCase")]
814pub enum NetworkResourceType {
815    Document,
816    Stylesheet,
817    Image,
818    Media,
819    Font,
820    Script,
821    TextTrack,
822    #[serde(rename = "XHR", alias = "Xhr")]
823    Xhr,
824    Fetch,
825    Prefetch,
826    EventSource,
827    WebSocket,
828    Manifest,
829    SignedExchange,
830    Ping,
831    #[serde(rename = "CSPViolationReport")]
832    CspViolationReport,
833    Preflight,
834    #[serde(rename = "FedCM")]
835    FedCm,
836    Other,
837}
838
839impl NetworkResourceType {
840    pub fn as_str(&self) -> &'static str {
841        match self {
842            Self::Document => "Document",
843            Self::Stylesheet => "Stylesheet",
844            Self::Image => "Image",
845            Self::Media => "Media",
846            Self::Font => "Font",
847            Self::Script => "Script",
848            Self::TextTrack => "TextTrack",
849            Self::Xhr => "XHR",
850            Self::Fetch => "Fetch",
851            Self::Prefetch => "Prefetch",
852            Self::EventSource => "EventSource",
853            Self::WebSocket => "WebSocket",
854            Self::Manifest => "Manifest",
855            Self::SignedExchange => "SignedExchange",
856            Self::Ping => "Ping",
857            Self::CspViolationReport => "CSPViolationReport",
858            Self::Preflight => "Preflight",
859            Self::FedCm => "FedCM",
860            Self::Other => "Other",
861        }
862    }
863}
864
865impl std::str::FromStr for NetworkResourceType {
866    type Err = String;
867
868    fn from_str(s: &str) -> Result<Self, Self::Err> {
869        match s {
870            "Document" | "document" => Ok(Self::Document),
871            "Stylesheet" | "stylesheet" => Ok(Self::Stylesheet),
872            "Image" | "image" => Ok(Self::Image),
873            "Media" | "media" => Ok(Self::Media),
874            "Font" | "font" => Ok(Self::Font),
875            "Script" | "script" => Ok(Self::Script),
876            "TextTrack" | "textTrack" | "texttrack" => Ok(Self::TextTrack),
877            "XHR" | "Xhr" | "xhr" => Ok(Self::Xhr),
878            "Fetch" | "fetch" => Ok(Self::Fetch),
879            "Prefetch" | "prefetch" => Ok(Self::Prefetch),
880            "EventSource" | "eventSource" | "eventsource" => Ok(Self::EventSource),
881            "WebSocket" | "webSocket" | "websocket" => Ok(Self::WebSocket),
882            "Manifest" | "manifest" => Ok(Self::Manifest),
883            "SignedExchange" | "signedExchange" => Ok(Self::SignedExchange),
884            "Ping" | "ping" => Ok(Self::Ping),
885            "CSPViolationReport" | "cspViolationReport" => Ok(Self::CspViolationReport),
886            "Preflight" | "preflight" => Ok(Self::Preflight),
887            "FedCM" | "FedCm" | "fedCM" => Ok(Self::FedCm),
888            "Other" | "other" => Ok(Self::Other),
889            other => Err(format!("unknown network resource type: {other}")),
890        }
891    }
892}
893
894#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
895#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
896#[serde(tag = "kind", rename_all = "camelCase")]
897pub enum WaitCondition {
898    Element {
899        target: Box<TargetSpec>,
900        state: ElementState,
901    },
902    Text {
903        target: Box<TargetSpec>,
904        matcher: TextMatch,
905    },
906    Value {
907        target: Box<TargetSpec>,
908        matcher: TextMatch,
909    },
910    Url {
911        matcher: TextMatch,
912    },
913    Document {
914        ready: WaitUntil,
915    },
916    NetworkQuiet {
917        #[serde(rename = "idleMs", alias = "idle_ms")]
918        idle_ms: u64,
919        #[serde(rename = "maxInFlight", alias = "max_in_flight")]
920        max_in_flight: usize,
921        #[serde(
922            default,
923            rename = "ignoreUrlSubstrings",
924            alias = "ignore_url_substrings",
925            skip_serializing_if = "Vec::is_empty"
926        )]
927        ignore_url_substrings: Vec<String>,
928        #[serde(
929            default,
930            rename = "ignoreResourceTypes",
931            alias = "ignore_resource_types",
932            skip_serializing_if = "Vec::is_empty"
933        )]
934        ignore_resource_types: Vec<NetworkResourceType>,
935        #[serde(
936            default,
937            rename = "ignoreLongLived",
938            alias = "ignore_long_lived",
939            skip_serializing_if = "std::ops::Not::not"
940        )]
941        ignore_long_lived: bool,
942    },
943}
944
945#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
946#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
947#[serde(rename_all = "camelCase")]
948pub struct WaitForCommand {
949    pub condition: WaitCondition,
950    pub timeout_ms: u64,
951}
952
953#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
954#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
955#[serde(tag = "kind", rename_all = "camelCase")]
956pub enum ScreenshotMode {
957    Viewport,
958    FullPage,
959    Element {
960        target: Box<TargetSpec>,
961    },
962    Clip {
963        x: f64,
964        y: f64,
965        width: f64,
966        height: f64,
967    },
968}
969
970#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
971#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
972#[serde(rename_all = "camelCase")]
973pub struct CaptureScreenshotCommand {
974    pub mode: ScreenshotMode,
975}
976
977#[derive(Debug, Clone, Serialize, Deserialize)]
978#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
979#[serde(rename_all = "camelCase")]
980pub struct SetFocusEmulationCommand {
981    pub enabled: bool,
982}
983
984#[derive(Debug, Clone, Serialize, Deserialize)]
985#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
986#[serde(rename_all = "camelCase")]
987pub struct SetEmulatedMediaCommand {
988    pub media: String,
989    pub features: BTreeMap<String, String>,
990}
991
992#[derive(Debug, Clone, Serialize, Deserialize)]
993#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
994#[serde(rename_all = "camelCase")]
995pub struct EvaluateJavaScriptCommand {
996    pub expression: String,
997    pub timeout_ms: u64,
998    #[serde(default)]
999    pub await_promise: bool,
1000}
1001
1002/// Per-session gate for privileged execution. Defaults to deny; a session must
1003/// opt in even when the bearer token holds the matching capability.
1004#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1005#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1006#[serde(rename_all = "camelCase")]
1007pub struct ExecutionPolicy {
1008    #[serde(default)]
1009    pub javascript_evaluation: bool,
1010    #[serde(default)]
1011    pub vision_assist: bool,
1012    /// Whether workers leased for this session apply fingerprint spoofing.
1013    ///
1014    /// Per session, never process-wide: it changes what the browser presents to
1015    /// the page, so the caller chooses it and the runtime records the choice.
1016    #[serde(default)]
1017    pub fingerprint: bool,
1018    /// Whether workers leased for this session synthesize human-like input
1019    /// timing (`behavioral-engine`) instead of driving the browser directly.
1020    ///
1021    /// Off by default: it changes observable timing on the intent execution
1022    /// path. When on, the synthesized timing is carried in
1023    /// `Evidence::Humanization` so intent verification still agrees.
1024    #[serde(default)]
1025    pub humanize: bool,
1026    /// Name of the registered vision node this session escalates to.
1027    ///
1028    /// `None` means no escalation, and naming an unconfigured node declines, so
1029    /// a session is never redirected to a provider it did not choose.
1030    #[serde(default, skip_serializing_if = "Option::is_none")]
1031    pub vision_node: Option<String>,
1032}
1033
1034/// `POST /v1/sessions` body.
1035#[derive(Debug, Clone, Serialize, Deserialize)]
1036#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1037#[serde(rename_all = "camelCase")]
1038pub struct CreateSessionRequest {
1039    pub profile: String,
1040    pub proxy: Option<String>,
1041    #[serde(default)]
1042    pub execution_policy: ExecutionPolicy,
1043    /// Godmode session: every execution-policy capability forced on, and
1044    /// every page-bound command runs under the ZigZagZig recovery ladder —
1045    /// a stuck command escalates through observe, re-resolve, retry,
1046    /// challenge solve, checkpoint, and session replacement automatically.
1047    /// Off by default; the ladder is opt-in per session.
1048    #[serde(default)]
1049    pub zigzagzig: bool,
1050}
1051
1052/// `POST /v1/pages` body.
1053#[derive(Debug, Clone, Serialize, Deserialize)]
1054#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1055pub struct OpenPageRequest {
1056    pub session_id: SessionId,
1057}
1058
1059#[derive(Debug, Clone, Serialize, Deserialize)]
1060#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1061pub struct NavigationRequest {
1062    pub page_id: PageId,
1063    pub url: String,
1064    pub wait_until: Option<String>,
1065    pub timeout_ms: Option<u64>,
1066}
1067
1068#[derive(Debug, Clone, Serialize, Deserialize)]
1069#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1070pub struct ExtractRequest {
1071    pub page_id: PageId,
1072    pub fields: serde_json::Value,
1073}