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