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