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