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