Skip to main content

bobby_browser_client/
commands.rs

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