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