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