Skip to main content

eoka_protocol/
lib.rs

1mod args;
2mod datadome;
3mod download;
4mod io;
5mod metadata;
6mod response;
7
8pub use args::*;
9pub use datadome::*;
10pub use download::*;
11pub use io::{read_msg, write_msg};
12pub use metadata::{OperationCapability, OperationExposure, ToolManifestEntry};
13pub use response::{ErrorDetail, Response, ResponseMeta};
14
15use serde::{Deserialize, Serialize};
16
17macro_rules! define_operations {
18    (
19        $(
20            $variant:ident {
21                path: $path:literal,
22                cmd: $cmd:literal,
23                name: $name:literal,
24                description: $description:literal,
25                capability: $capability:ident,
26                exposure: $exposure:ident,
27                read_only: $read_only:literal,
28                destructive: $destructive:literal
29                $(, input: $input:ty)?
30                $(,)?
31            }
32        ),+ $(,)?
33    ) => {
34        #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
35        pub enum OperationId {
36            $($variant),+
37        }
38
39        #[derive(Debug, Clone, Copy)]
40        pub struct OperationSpec {
41            pub id: OperationId,
42            pub path: &'static str,
43            pub cmd: &'static str,
44            pub name: &'static str,
45            pub description: &'static str,
46            pub capability: OperationCapability,
47            pub exposure: OperationExposure,
48            pub read_only: bool,
49            pub destructive: bool,
50        }
51
52        #[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
53        #[serde(tag = "cmd", content = "args")]
54        pub enum Request {
55            $(
56                #[serde(rename = $cmd)]
57                $variant$(($input))?,
58            )+
59        }
60
61        pub const OPERATIONS: &[OperationSpec] = &[
62            $(OperationSpec {
63                id: OperationId::$variant,
64                path: $path,
65                cmd: $cmd,
66                name: $name,
67                description: $description,
68                capability: OperationCapability::$capability,
69                exposure: OperationExposure::$exposure,
70                read_only: $read_only,
71                destructive: $destructive,
72            }),+
73        ];
74        pub fn all_operations() -> &'static [OperationSpec] {
75            OPERATIONS
76        }
77
78        pub fn operation_by_path(path: &str) -> Option<&'static OperationSpec> {
79            OPERATIONS.iter().find(|operation| operation.path == path)
80        }
81
82        pub fn operation_by_cmd(cmd: &str) -> Option<&'static OperationSpec> {
83            OPERATIONS.iter().find(|operation| operation.cmd == cmd)
84        }
85
86        pub fn default_agent_operations() -> impl Iterator<Item = &'static OperationSpec> {
87            OPERATIONS
88                .iter()
89                .filter(|operation| operation.exposure == OperationExposure::DefaultAgent)
90        }
91
92        pub fn exposed_operations(
93            include_opt_in: bool,
94        ) -> impl Iterator<Item = &'static OperationSpec> {
95            OPERATIONS.iter().filter(move |operation| {
96                operation.exposure == OperationExposure::DefaultAgent
97                    || (include_opt_in && operation.exposure == OperationExposure::OptIn)
98            })
99        }
100
101        pub fn request_from_operation_path(
102            path: &str,
103            input: serde_json::Value,
104        ) -> Result<Request, String> {
105            let operation = operation_by_path(path)
106                .ok_or_else(|| format!("unknown eoka operation path: {path}"))?;
107            request_from_cmd(operation.cmd, input)
108        }
109
110        pub fn request_from_cmd(cmd: &str, input: serde_json::Value) -> Result<Request, String> {
111            match cmd {
112                $(
113                    $cmd => Ok(define_operations!(@request Request::$variant, input $(, $input)?)),
114                )+
115                _ => Err(format!("unknown eoka protocol command: {cmd}")),
116            }
117        }
118
119        pub fn input_schema_for_cmd(cmd: &str) -> serde_json::Value {
120            match cmd {
121                $(
122                    $cmd => define_operations!(@schema $( $input )?),
123                )+
124                _ => serde_json::json!({ "type": "object", "additionalProperties": false }),
125            }
126        }
127
128        pub fn input_schema_for_operation(operation: &OperationSpec) -> serde_json::Value {
129            input_schema_for_cmd(operation.cmd)
130        }
131
132        pub fn tags_for_operation(operation: &OperationSpec) -> Vec<&'static str> {
133            vec!["eoka", operation.capability.as_str()]
134        }
135
136        pub fn manifest_entry_for_operation(
137            namespace: &str,
138            operation: &OperationSpec,
139        ) -> ToolManifestEntry {
140            ToolManifestEntry {
141                path: format!("{}.{}", namespace, operation.path),
142                cmd: operation.cmd,
143                name: operation.name,
144                description: operation.description,
145                capability: operation.capability.as_str(),
146                exposure: operation.exposure.as_str(),
147                read_only: operation.read_only,
148                destructive: operation.destructive,
149                input_schema: input_schema_for_operation(operation),
150                tags: tags_for_operation(operation),
151            }
152        }
153
154        pub fn manifest_for_operations(
155            namespace: &str,
156            include_opt_in: bool,
157        ) -> Vec<ToolManifestEntry> {
158            exposed_operations(include_opt_in)
159                .map(|operation| manifest_entry_for_operation(namespace, operation))
160                .collect()
161        }
162
163        impl Request {
164            pub fn cmd(&self) -> &'static str {
165                match self {
166                    $(
167                        define_operations!(@match_variant Self::$variant $(, $input)?) => $cmd,
168                    )+
169                }
170            }
171
172            pub fn args_json(&self) -> serde_json::Value {
173                serde_json::to_value(self)
174                    .ok()
175                    .and_then(|value| value.get("args").cloned())
176                    .unwrap_or_else(|| serde_json::json!({}))
177            }
178        }
179    };
180
181    (@request $variant:path, $input:ident, $ty:ty) => {{
182        serde_json::from_value::<$ty>($input)
183            .map($variant)
184            .map_err(|error| error.to_string())?
185    }};
186
187    (@request $variant:path, $input:ident) => {{
188        if $input != serde_json::json!({}) && $input != serde_json::Value::Null {
189            return Err("expected empty object for zero-argument command".to_string());
190        }
191        $variant
192    }};
193
194    (@schema $ty:ty) => {
195        schema_for::<$ty>()
196    };
197
198    (@schema) => {
199        serde_json::json!({ "type": "object", "additionalProperties": false })
200    };
201
202    (@match_variant $variant:path, $ty:ty) => {
203        $variant(_)
204    };
205
206    (@match_variant $variant:path) => {
207        $variant
208    };
209
210}
211
212define_operations! {
213    Open {
214        path: "open",
215        cmd: "open",
216        name: "open",
217        description: "Navigate to URL",
218        capability: Navigation,
219        exposure: DefaultAgent,
220        read_only: false,
221        destructive: false,
222        input: OpenArgs,
223    },
224    Back {
225        path: "back",
226        cmd: "back",
227        name: "back",
228        description: "Go back",
229        capability: Navigation,
230        exposure: DefaultAgent,
231        read_only: false,
232        destructive: false,
233    },
234    Forward {
235        path: "forward",
236        cmd: "forward",
237        name: "forward",
238        description: "Go forward",
239        capability: Navigation,
240        exposure: DefaultAgent,
241        read_only: false,
242        destructive: false,
243    },
244    Reload {
245        path: "reload",
246        cmd: "reload",
247        name: "reload",
248        description: "Reload page",
249        capability: Navigation,
250        exposure: DefaultAgent,
251        read_only: false,
252        destructive: false,
253    },
254    Snapshot {
255        path: "snapshot",
256        cmd: "snapshot",
257        name: "snapshot",
258        description: "Accessibility snapshot",
259        capability: Observation,
260        exposure: DefaultAgent,
261        read_only: true,
262        destructive: false,
263        input: SnapshotArgs,
264    },
265    Observe {
266        path: "observe",
267        cmd: "observe",
268        name: "observe",
269        description: "Observe interactive elements",
270        capability: Observation,
271        exposure: DefaultAgent,
272        read_only: true,
273        destructive: false,
274        input: ObserveArgs,
275    },
276    Screenshot {
277        path: "screenshot",
278        cmd: "screenshot",
279        name: "screenshot",
280        description: "Take screenshot",
281        capability: Observation,
282        exposure: DefaultAgent,
283        read_only: true,
284        destructive: false,
285        input: ScreenshotArgs,
286    },
287    Emulate {
288        path: "emulate",
289        cmd: "emulate",
290        name: "emulate",
291        description: "Emulate viewport",
292        capability: Navigation,
293        exposure: DefaultAgent,
294        read_only: false,
295        destructive: false,
296        input: EmulateArgs,
297    },
298    Info {
299        path: "info",
300        cmd: "info",
301        name: "info",
302        description: "Page URL and title",
303        capability: Observation,
304        exposure: DefaultAgent,
305        read_only: true,
306        destructive: false,
307    },
308    Text {
309        path: "text",
310        cmd: "text",
311        name: "text",
312        description: "Visible page text",
313        capability: Observation,
314        exposure: DefaultAgent,
315        read_only: true,
316        destructive: false,
317    },
318    Find {
319        path: "find",
320        cmd: "find",
321        name: "find",
322        description: "Find elements by text",
323        capability: Observation,
324        exposure: DefaultAgent,
325        read_only: true,
326        destructive: false,
327        input: TextArgs,
328    },
329    Click {
330        path: "click",
331        cmd: "click",
332        name: "click",
333        description: "Click target",
334        capability: Interaction,
335        exposure: DefaultAgent,
336        read_only: false,
337        destructive: true,
338        input: TargetArgs,
339    },
340    DblClick {
341        path: "double_click",
342        cmd: "dblclick",
343        name: "double_click",
344        description: "Double click target",
345        capability: Interaction,
346        exposure: DefaultAgent,
347        read_only: false,
348        destructive: true,
349        input: TargetArgs,
350    },
351    Fill {
352        path: "fill",
353        cmd: "fill",
354        name: "fill",
355        description: "Fill input",
356        capability: Interaction,
357        exposure: DefaultAgent,
358        read_only: false,
359        destructive: true,
360        input: FillArgs,
361    },
362    Select {
363        path: "select",
364        cmd: "select",
365        name: "select",
366        description: "Select option",
367        capability: Interaction,
368        exposure: DefaultAgent,
369        read_only: false,
370        destructive: true,
371        input: SelectArgs,
372    },
373    Hover {
374        path: "hover",
375        cmd: "hover",
376        name: "hover",
377        description: "Hover target",
378        capability: Interaction,
379        exposure: DefaultAgent,
380        read_only: false,
381        destructive: false,
382        input: TargetArgs,
383    },
384    Key {
385        path: "key",
386        cmd: "key",
387        name: "key",
388        description: "Press key",
389        capability: Interaction,
390        exposure: DefaultAgent,
391        read_only: false,
392        destructive: true,
393        input: KeyArgs,
394    },
395    MouseDown {
396        path: "mouse.down",
397        cmd: "mouse_down",
398        name: "mouse_down",
399        description: "Press and hold mouse button at viewport coordinates",
400        capability: Interaction,
401        exposure: DefaultAgent,
402        read_only: false,
403        destructive: true,
404        input: MouseButtonArgs,
405    },
406    MouseMove {
407        path: "mouse.move",
408        cmd: "mouse_move",
409        name: "mouse_move",
410        description: "Move mouse to viewport coordinates",
411        capability: Interaction,
412        exposure: DefaultAgent,
413        read_only: false,
414        destructive: false,
415        input: MouseMoveArgs,
416    },
417    MouseUp {
418        path: "mouse.up",
419        cmd: "mouse_up",
420        name: "mouse_up",
421        description: "Release held mouse button at viewport coordinates",
422        capability: Interaction,
423        exposure: DefaultAgent,
424        read_only: false,
425        destructive: true,
426        input: MouseButtonArgs,
427    },
428    KeyDown {
429        path: "key.down",
430        cmd: "key_down",
431        name: "key_down",
432        description: "Press and hold key",
433        capability: Interaction,
434        exposure: DefaultAgent,
435        read_only: false,
436        destructive: true,
437        input: KeyArgs,
438    },
439    KeyUp {
440        path: "key.up",
441        cmd: "key_up",
442        name: "key_up",
443        description: "Release held key",
444        capability: Interaction,
445        exposure: DefaultAgent,
446        read_only: false,
447        destructive: true,
448        input: KeyArgs,
449    },
450    ReleaseAllInputs {
451        path: "release_all_inputs",
452        cmd: "release_all_inputs",
453        name: "release_all_inputs",
454        description: "Release every held mouse button and key",
455        capability: Interaction,
456        exposure: DefaultAgent,
457        read_only: false,
458        destructive: true,
459    },
460    Scroll {
461        path: "scroll",
462        cmd: "scroll",
463        name: "scroll",
464        description: "Scroll page or target",
465        capability: Interaction,
466        exposure: DefaultAgent,
467        read_only: false,
468        destructive: false,
469        input: TargetArgs,
470    },
471    Eval {
472        path: "eval",
473        cmd: "eval",
474        name: "eval",
475        description: "Evaluate JavaScript",
476        capability: JavaScript,
477        exposure: DefaultAgent,
478        read_only: false,
479        destructive: false,
480        input: ScriptArgs,
481    },
482    Exec {
483        path: "exec",
484        cmd: "exec",
485        name: "exec",
486        description: "Execute JavaScript",
487        capability: JavaScript,
488        exposure: DefaultAgent,
489        read_only: false,
490        destructive: true,
491        input: ScriptArgs,
492    },
493    Frames {
494        path: "frames",
495        cmd: "frames",
496        name: "frames",
497        description: "List the active page's frame IDs, URLs and names, including nested OOPIFs",
498        capability: Observation,
499        exposure: DefaultAgent,
500        read_only: true,
501        destructive: false,
502    },
503    FrameEval {
504        path: "frame_eval",
505        cmd: "frame_eval",
506        name: "frame_eval",
507        description: "Evaluate JavaScript in an isolated frame world by CSS selector or id:<frame-id>",
508        capability: JavaScript,
509        exposure: DefaultAgent,
510        read_only: false,
511        destructive: false,
512        input: FrameEvalArgs,
513    },
514    DownloadStart {
515        path: "download.start",
516        cmd: "download_start",
517        name: "download.start",
518        description: "Start one native browser download to an absolute output filename; returns a background job",
519        capability: Network,
520        exposure: DefaultAgent,
521        read_only: false,
522        destructive: true,
523        input: DownloadStartArgs,
524    },
525    DownloadList {
526        path: "download.list",
527        cmd: "download_list",
528        name: "download.list",
529        description: "List download jobs in the existing session",
530        capability: Network,
531        exposure: DefaultAgent,
532        read_only: true,
533        destructive: false,
534    },
535    DownloadGet {
536        path: "download.get",
537        cmd: "download_get",
538        name: "download.get",
539        description: "Inspect a download job and its saved artifact",
540        capability: Network,
541        exposure: DefaultAgent,
542        read_only: true,
543        destructive: false,
544        input: DownloadIdArgs,
545    },
546    DownloadWait {
547        path: "download.wait",
548        cmd: "download_wait",
549        name: "download.wait",
550        description: "Wait for a download; timeout or caller disconnection does not cancel the transfer",
551        capability: Network,
552        exposure: DefaultAgent,
553        read_only: true,
554        destructive: false,
555        input: DownloadWaitArgs,
556    },
557    DownloadCancel {
558        path: "download.cancel",
559        cmd: "download_cancel",
560        name: "download.cancel",
561        description: "Request cancellation of a download without deleting a completed artifact",
562        capability: Network,
563        exposure: DefaultAgent,
564        read_only: false,
565        destructive: true,
566        input: DownloadIdArgs,
567    },
568    Fetch {
569        path: "fetch",
570        cmd: "fetch",
571        name: "fetch",
572        description: "Inspect bounded text using browser credentials; binary files use download.start",
573        capability: Network,
574        exposure: DefaultAgent,
575        read_only: false,
576        destructive: false,
577        input: FetchArgs,
578    },
579    Cookies {
580        path: "cookies",
581        cmd: "cookies",
582        name: "cookies",
583        description: "List cookies",
584        capability: BrowserState,
585        exposure: DefaultAgent,
586        read_only: true,
587        destructive: false,
588    },
589    SetCookie {
590        path: "set_cookie",
591        cmd: "set_cookie",
592        name: "set_cookie",
593        description: "Set cookie",
594        capability: BrowserState,
595        exposure: DefaultAgent,
596        read_only: false,
597        destructive: true,
598        input: SetCookieArgs,
599    },
600    DeleteCookie {
601        path: "delete_cookie",
602        cmd: "delete_cookie",
603        name: "delete_cookie",
604        description: "Delete cookie",
605        capability: BrowserState,
606        exposure: DefaultAgent,
607        read_only: false,
608        destructive: true,
609        input: DeleteCookieArgs,
610    },
611    ClearCookies {
612        path: "clear_cookies",
613        cmd: "clear_cookies",
614        name: "clear_cookies",
615        description: "Clear cookies",
616        capability: BrowserState,
617        exposure: DefaultAgent,
618        read_only: false,
619        destructive: true,
620    },
621    Storage {
622        path: "storage",
623        cmd: "storage",
624        name: "storage",
625        description: "Read storage",
626        capability: BrowserState,
627        exposure: DefaultAgent,
628        read_only: true,
629        destructive: false,
630        input: StorageArgs,
631    },
632    SetStorage {
633        path: "set_storage",
634        cmd: "set_storage",
635        name: "set_storage",
636        description: "Set storage",
637        capability: BrowserState,
638        exposure: DefaultAgent,
639        read_only: false,
640        destructive: true,
641        input: SetStorageArgs,
642    },
643    DumpStorage {
644        path: "dump_storage",
645        cmd: "dump_storage",
646        name: "dump_storage",
647        description: "Dump storage",
648        capability: BrowserState,
649        exposure: DefaultAgent,
650        read_only: true,
651        destructive: false,
652    },
653    SaveState {
654        path: "save_state",
655        cmd: "save_state",
656        name: "save_state",
657        description: "Save browser state",
658        capability: BrowserState,
659        exposure: DefaultAgent,
660        read_only: true,
661        destructive: false,
662        input: PathArgs,
663    },
664    LoadState {
665        path: "load_state",
666        cmd: "load_state",
667        name: "load_state",
668        description: "Load browser state",
669        capability: BrowserState,
670        exposure: DefaultAgent,
671        read_only: false,
672        destructive: true,
673        input: LoadStateArgs,
674    },
675    Headers {
676        path: "headers",
677        cmd: "headers",
678        name: "headers",
679        description: "Set extra headers",
680        capability: Network,
681        exposure: DefaultAgent,
682        read_only: false,
683        destructive: false,
684        input: HeadersArgs,
685    },
686    Console {
687        path: "console",
688        cmd: "console",
689        name: "console",
690        description: "Read console output",
691        capability: Observation,
692        exposure: DefaultAgent,
693        read_only: true,
694        destructive: false,
695        input: ConsoleArgs,
696    },
697    Errors {
698        path: "errors",
699        cmd: "errors",
700        name: "errors",
701        description: "Read JavaScript errors",
702        capability: Observation,
703        exposure: DefaultAgent,
704        read_only: true,
705        destructive: false,
706        input: ClearFlagArgs,
707    },
708    TabList {
709        path: "tab.list",
710        cmd: "tab_list",
711        name: "tab.list",
712        description: "List tabs",
713        capability: Tabs,
714        exposure: DefaultAgent,
715        read_only: true,
716        destructive: false,
717    },
718    TabNew {
719        path: "tab.new",
720        cmd: "tab_new",
721        name: "tab.new",
722        description: "Open new tab",
723        capability: Tabs,
724        exposure: DefaultAgent,
725        read_only: false,
726        destructive: false,
727        input: TabNewArgs,
728    },
729    TabSwitch {
730        path: "tab.switch",
731        cmd: "tab_switch",
732        name: "tab.switch",
733        description: "Switch tab",
734        capability: Tabs,
735        exposure: DefaultAgent,
736        read_only: false,
737        destructive: false,
738        input: TabIdArgs,
739    },
740    TabClose {
741        path: "tab.close",
742        cmd: "tab_close",
743        name: "tab.close",
744        description: "Close tab",
745        capability: Tabs,
746        exposure: DefaultAgent,
747        read_only: false,
748        destructive: true,
749        input: TabIdArgs,
750    },
751    TabAttach {
752        path: "tab.attach",
753        cmd: "tab_attach",
754        name: "tab.attach",
755        description: "Attach tab",
756        capability: Tabs,
757        exposure: DefaultAgent,
758        read_only: false,
759        destructive: false,
760        input: TabIdArgs,
761    },
762    CloneFrom {
763        path: "clone_from",
764        cmd: "clone_from",
765        name: "clone_from",
766        description: "Clone browser state",
767        capability: BrowserState,
768        exposure: DefaultAgent,
769        read_only: false,
770        destructive: true,
771        input: CloneFromArgs,
772    },
773    Wait {
774        path: "wait",
775        cmd: "wait",
776        name: "wait",
777        description: "Wait for page condition",
778        capability: Observation,
779        exposure: DefaultAgent,
780        read_only: true,
781        destructive: false,
782        input: WaitArgs,
783    },
784    SpaInfo {
785        path: "spa.info",
786        cmd: "spa_info",
787        name: "spa.info",
788        description: "SPA routing info",
789        capability: Spa,
790        exposure: DefaultAgent,
791        read_only: true,
792        destructive: false,
793    },
794    SpaNavigate {
795        path: "spa.navigate",
796        cmd: "spa_navigate",
797        name: "spa.navigate",
798        description: "SPA navigation",
799        capability: Spa,
800        exposure: DefaultAgent,
801        read_only: false,
802        destructive: false,
803        input: PathStringArgs,
804    },
805    FakeCamera {
806        path: "fake_camera",
807        cmd: "fake_camera",
808        name: "fake_camera",
809        description: "Inject fake camera",
810        capability: Media,
811        exposure: DefaultAgent,
812        read_only: false,
813        destructive: true,
814        input: FakeCameraArgs,
815    },
816    WasmInfo {
817        path: "wasm.info",
818        cmd: "wasm_info",
819        name: "wasm.info",
820        description: "WASM memory info",
821        capability: Wasm,
822        exposure: DefaultAgent,
823        read_only: true,
824        destructive: false,
825    },
826    WasmRead {
827        path: "wasm.read",
828        cmd: "wasm_read",
829        name: "wasm.read",
830        description: "Read WASM memory",
831        capability: Wasm,
832        exposure: DefaultAgent,
833        read_only: true,
834        destructive: false,
835        input: WasmReadArgs,
836    },
837    WasmWrite {
838        path: "wasm.write",
839        cmd: "wasm_write",
840        name: "wasm.write",
841        description: "Write WASM memory",
842        capability: Wasm,
843        exposure: DefaultAgent,
844        read_only: false,
845        destructive: true,
846        input: WasmWriteArgs,
847    },
848    WasmFind {
849        path: "wasm.find",
850        cmd: "wasm_find",
851        name: "wasm.find",
852        description: "Find WASM memory pattern",
853        capability: Wasm,
854        exposure: DefaultAgent,
855        read_only: true,
856        destructive: false,
857        input: WasmFindArgs,
858    },
859    InterceptAdd {
860        path: "intercept.add",
861        cmd: "intercept_add",
862        name: "intercept.add",
863        description: "Add network interception rule",
864        capability: Network,
865        exposure: OptIn,
866        read_only: false,
867        destructive: true,
868        input: InterceptAddArgs,
869    },
870    InterceptList {
871        path: "intercept.list",
872        cmd: "intercept_list",
873        name: "intercept.list",
874        description: "List network interception rules",
875        capability: Network,
876        exposure: OptIn,
877        read_only: true,
878        destructive: false,
879    },
880    InterceptRemove {
881        path: "intercept.remove",
882        cmd: "intercept_remove",
883        name: "intercept.remove",
884        description: "Remove network interception rule",
885        capability: Network,
886        exposure: OptIn,
887        read_only: false,
888        destructive: true,
889        input: IdArgs,
890    },
891    InterceptLog {
892        path: "intercept.log",
893        cmd: "intercept_log",
894        name: "intercept.log",
895        description: "Read intercepted request log",
896        capability: Network,
897        exposure: OptIn,
898        read_only: true,
899        destructive: false,
900        input: ClearFlagArgs,
901    },
902    JsMode {
903        path: "js.mode",
904        cmd: "js_mode",
905        name: "js.mode",
906        description: "Set JavaScript policy mode",
907        capability: Policy,
908        exposure: DefaultAgent,
909        read_only: false,
910        destructive: false,
911        input: ModeArgs,
912    },
913    JsAllow {
914        path: "js.allow",
915        cmd: "js_allow",
916        name: "js.allow",
917        description: "Allow JavaScript domain",
918        capability: Policy,
919        exposure: DefaultAgent,
920        read_only: false,
921        destructive: false,
922        input: DomainArgs,
923    },
924    JsBlock {
925        path: "js.block",
926        cmd: "js_block",
927        name: "js.block",
928        description: "Block JavaScript domain",
929        capability: Policy,
930        exposure: DefaultAgent,
931        read_only: false,
932        destructive: false,
933        input: DomainArgs,
934    },
935    JsRemove {
936        path: "js.remove",
937        cmd: "js_remove",
938        name: "js.remove",
939        description: "Remove JavaScript domain rule",
940        capability: Policy,
941        exposure: DefaultAgent,
942        read_only: false,
943        destructive: true,
944        input: DomainArgs,
945    },
946    JsList {
947        path: "js.list",
948        cmd: "js_list",
949        name: "js.list",
950        description: "List JavaScript policy",
951        capability: Policy,
952        exposure: DefaultAgent,
953        read_only: true,
954        destructive: false,
955    },
956    NetworkRecordStart {
957        path: "network.record.start",
958        cmd: "network_record_start",
959        name: "network.record.start",
960        description: "Start network recording",
961        capability: Network,
962        exposure: OptIn,
963        read_only: false,
964        destructive: false,
965        input: NetworkRecordStartArgs,
966    },
967    NetworkRecordStop {
968        path: "network.record.stop",
969        cmd: "network_record_stop",
970        name: "network.record.stop",
971        description: "Stop network recording",
972        capability: Network,
973        exposure: OptIn,
974        read_only: false,
975        destructive: false,
976    },
977    NetworkRecordStatus {
978        path: "network.record.status",
979        cmd: "network_record_status",
980        name: "network.record.status",
981        description: "Network recorder status",
982        capability: Network,
983        exposure: OptIn,
984        read_only: true,
985        destructive: false,
986    },
987    NetworkLog {
988        path: "network.log",
989        cmd: "network_log",
990        name: "network.log",
991        description: "Read network log",
992        capability: Network,
993        exposure: OptIn,
994        read_only: true,
995        destructive: false,
996        input: NetworkLogArgs,
997    },
998    NetworkShow {
999        path: "network.show",
1000        cmd: "network_show",
1001        name: "network.show",
1002        description: "Show network entry details",
1003        capability: Network,
1004        exposure: OptIn,
1005        read_only: true,
1006        destructive: false,
1007        input: NetworkShowArgs,
1008    },
1009    NetworkWait {
1010        path: "network.wait",
1011        cmd: "network_wait",
1012        name: "network.wait",
1013        description: "Wait for a matching network request",
1014        capability: Network,
1015        exposure: OptIn,
1016        read_only: true,
1017        destructive: false,
1018        input: NetworkWaitArgs,
1019    },
1020    NetworkExport {
1021        path: "network.export",
1022        cmd: "network_export",
1023        name: "network.export",
1024        description: "Export network traffic",
1025        capability: Network,
1026        exposure: OptIn,
1027        read_only: true,
1028        destructive: false,
1029        input: NetworkExportArgs,
1030    },
1031    NetworkClear {
1032        path: "network.clear",
1033        cmd: "network_clear",
1034        name: "network.clear",
1035        description: "Clear network log",
1036        capability: Network,
1037        exposure: OptIn,
1038        read_only: false,
1039        destructive: true,
1040    },
1041    Close {
1042        path: "close",
1043        cmd: "close",
1044        name: "close",
1045        description: "Close browser session",
1046        capability: Lifecycle,
1047        exposure: Lifecycle,
1048        read_only: false,
1049        destructive: true,
1050    },
1051    Shutdown {
1052        path: "shutdown",
1053        cmd: "shutdown",
1054        name: "shutdown",
1055        description: "Shut down daemon",
1056        capability: Lifecycle,
1057        exposure: Lifecycle,
1058        read_only: false,
1059        destructive: true,
1060    },
1061    CaptchaDatadome {
1062        path: "captcha.datadome",
1063        cmd: "captcha_datadome",
1064        name: "captcha.datadome",
1065        description: "Attempt local DataDome clearance on the existing current tab; not an authentication claim",
1066        capability: Captcha,
1067        exposure: OptIn,
1068        read_only: false,
1069        destructive: true,
1070        input: CaptchaDatadomeArgs,
1071    },
1072    CaptchaInject {
1073        path: "captcha.inject",
1074        cmd: "captcha_inject",
1075        name: "captcha.inject",
1076        description: "Inject a solved CAPTCHA token",
1077        capability: Captcha,
1078        exposure: OptIn,
1079        read_only: false,
1080        destructive: true,
1081        input: CaptchaInjectArgs,
1082    },
1083}
1084
1085fn schema_for<T: schemars::JsonSchema>() -> serde_json::Value {
1086    serde_json::to_value(schemars::schema_for!(T))
1087        .unwrap_or_else(|_| serde_json::json!({ "type": "object", "additionalProperties": true }))
1088}
1089
1090#[cfg(test)]
1091mod tests {
1092    use super::*;
1093    use tokio::io::AsyncWriteExt;
1094    use tokio::net::{UnixListener, UnixStream};
1095
1096    fn temp_socket_path(name: &str) -> std::path::PathBuf {
1097        std::env::temp_dir().join(format!(
1098            "eoka-protocol-test-{}-{}.sock",
1099            std::process::id(),
1100            name
1101        ))
1102    }
1103
1104    #[tokio::test]
1105    async fn round_trip_over_unix_socket() {
1106        let sock_path = temp_socket_path("roundtrip");
1107        let _ = std::fs::remove_file(&sock_path);
1108        let listener = UnixListener::bind(&sock_path).unwrap();
1109
1110        let server = tokio::spawn(async move {
1111            let (stream, _) = listener.accept().await.unwrap();
1112            let (mut reader, mut writer) = stream.into_split();
1113            let req: Request = read_msg(&mut reader).await.unwrap();
1114            assert_eq!(req.cmd(), "click");
1115            assert_eq!(req.args_json(), serde_json::json!({ "target": "@e1" }));
1116            write_msg(&mut writer, &Response::ok_text("pong"))
1117                .await
1118                .unwrap();
1119        });
1120
1121        let stream = UnixStream::connect(&sock_path).await.unwrap();
1122        let (mut reader, mut writer) = stream.into_split();
1123        write_msg(
1124            &mut writer,
1125            &Request::Click(TargetArgs {
1126                target: "@e1".into(),
1127            }),
1128        )
1129        .await
1130        .unwrap();
1131        let response: Response = read_msg(&mut reader).await.unwrap();
1132
1133        server.await.unwrap();
1134        let _ = std::fs::remove_file(&sock_path);
1135
1136        assert!(response.ok);
1137        assert_eq!(
1138            response.data,
1139            Some(serde_json::Value::String("pong".into()))
1140        );
1141        assert_eq!(response.error, None);
1142    }
1143
1144    #[tokio::test]
1145    async fn read_msg_rejects_oversized_length_prefix() {
1146        let sock_path = temp_socket_path("oversized");
1147        let _ = std::fs::remove_file(&sock_path);
1148        let listener = UnixListener::bind(&sock_path).unwrap();
1149
1150        let server = tokio::spawn(async move {
1151            let (stream, _) = listener.accept().await.unwrap();
1152            let (mut reader, _writer) = stream.into_split();
1153            let result: std::io::Result<Request> = read_msg(&mut reader).await;
1154            assert!(result.is_err());
1155            assert!(result.unwrap_err().to_string().contains("too large"));
1156        });
1157
1158        let mut stream = UnixStream::connect(&sock_path).await.unwrap();
1159        let oversized_len: u32 = 65 * 1024 * 1024;
1160        stream
1161            .write_all(&oversized_len.to_be_bytes())
1162            .await
1163            .unwrap();
1164
1165        server.await.unwrap();
1166        let _ = std::fs::remove_file(&sock_path);
1167    }
1168
1169    #[test]
1170    fn typed_network_request_round_trips_with_cmd_and_args() {
1171        let request = Request::NetworkWait(NetworkWaitArgs {
1172            pattern: Some("*/api/*".into()),
1173            method: Some("POST".into()),
1174            status: Some(201),
1175            timeout: Some(5000),
1176            since: Some(10),
1177            include_existing: false,
1178        });
1179
1180        let value = serde_json::to_value(&request).unwrap();
1181        assert_eq!(value["cmd"], "network_wait");
1182        assert_eq!(value["args"]["pattern"], "*/api/*");
1183
1184        let parsed: Request = serde_json::from_value(value).unwrap();
1185        assert_eq!(parsed.cmd(), "network_wait");
1186        assert_eq!(parsed.args_json()["status"], 201);
1187    }
1188
1189    #[test]
1190    fn typed_request_rejects_unknown_command() {
1191        let result: Result<Request, _> =
1192            serde_json::from_value(serde_json::json!({ "cmd": "not_real", "args": {} }));
1193
1194        assert!(result.is_err());
1195    }
1196
1197    #[test]
1198    fn operation_catalog_maps_stable_paths_to_protocol_commands() {
1199        let tab = operation_by_path("tab.new").unwrap();
1200        assert_eq!(tab.cmd, "tab_new");
1201        assert_eq!(tab.exposure, OperationExposure::DefaultAgent);
1202
1203        let request = request_from_operation_path(
1204            "tab.new",
1205            serde_json::json!({"url":"https://example.com"}),
1206        )
1207        .unwrap();
1208        assert_eq!(request.cmd(), "tab_new");
1209        assert_eq!(request.args_json()["url"], "https://example.com");
1210    }
1211
1212    #[test]
1213    fn default_catalog_excludes_opt_in_and_lifecycle_operations() {
1214        let paths: Vec<&str> = default_agent_operations()
1215            .map(|operation| operation.path)
1216            .collect();
1217
1218        assert!(paths.contains(&"open"));
1219        assert!(paths.contains(&"mouse.down"));
1220        assert!(paths.contains(&"key.down"));
1221        assert!(paths.contains(&"release_all_inputs"));
1222        assert!(paths.contains(&"tab.list"));
1223        assert!(!paths.contains(&"network.log"));
1224        assert!(!paths.contains(&"close"));
1225    }
1226
1227    #[test]
1228    fn operation_catalog_has_unique_paths_and_commands() {
1229        let mut paths = std::collections::BTreeSet::new();
1230        let mut commands = std::collections::BTreeSet::new();
1231        let mut ids = std::collections::BTreeSet::new();
1232
1233        for operation in all_operations() {
1234            assert!(ids.insert(operation.id), "duplicate id {:?}", operation.id);
1235            assert!(
1236                paths.insert(operation.path),
1237                "duplicate path {}",
1238                operation.path
1239            );
1240            assert!(
1241                commands.insert(operation.cmd),
1242                "duplicate cmd {}",
1243                operation.cmd
1244            );
1245            assert_eq!(operation_by_path(operation.path).unwrap().id, operation.id);
1246            assert_eq!(operation_by_cmd(operation.cmd).unwrap().id, operation.id);
1247            assert_eq!(
1248                request_from_operation_path(
1249                    operation.path,
1250                    operation.representative_input_for_test()
1251                )
1252                .unwrap()
1253                .cmd(),
1254                operation.cmd
1255            );
1256        }
1257    }
1258
1259    #[test]
1260    fn manifest_uses_catalog_metadata_and_schemas() {
1261        let default_manifest = manifest_for_operations("eoka", false);
1262        let paths: std::collections::BTreeSet<&str> = default_manifest
1263            .iter()
1264            .map(|entry| entry.path.as_str())
1265            .collect();
1266
1267        assert!(paths.contains("eoka.open"));
1268        assert!(paths.contains("eoka.mouse.down"));
1269        assert!(paths.contains("eoka.key.down"));
1270        assert!(paths.contains("eoka.release_all_inputs"));
1271        assert!(paths.contains("eoka.tab.list"));
1272        assert!(!paths.contains("eoka.network.log"));
1273        assert!(!paths.contains("eoka.close"));
1274
1275        let open = default_manifest
1276            .iter()
1277            .find(|entry| entry.path == "eoka.open")
1278            .unwrap();
1279        assert_eq!(open.capability, "navigation");
1280        assert_eq!(open.exposure, "defaultAgent");
1281        assert_eq!(open.tags, vec!["eoka", "navigation"]);
1282        assert!(open.input_schema.get("properties").is_some());
1283
1284        let all_manifest = manifest_for_operations("eoka", true);
1285        let all_paths: std::collections::BTreeSet<&str> = all_manifest
1286            .iter()
1287            .map(|entry| entry.path.as_str())
1288            .collect();
1289        assert!(all_paths.contains("eoka.network.log"));
1290        assert!(!all_paths.contains("eoka.close"));
1291    }
1292
1293    #[test]
1294    fn shared_mcp_inputs_keep_schema_descriptions() {
1295        let click_schema = input_schema_for_cmd("click");
1296        assert_eq!(
1297            click_schema["properties"]["target"]["description"],
1298            "Target element. Supports index, snapshot refs like @e1, text:Submit, placeholder:Email, role:button, css:form button, id:my-btn, or plain text search."
1299        );
1300
1301        let tab_schema = input_schema_for_cmd("tab_switch");
1302        assert_eq!(
1303            tab_schema["properties"]["tab_id"]["description"],
1304            "Tab ID from list_tabs."
1305        );
1306    }
1307
1308    impl OperationSpec {
1309        fn representative_input_for_test(&self) -> serde_json::Value {
1310            match self.cmd {
1311                "open" => serde_json::json!({"url":"about:blank"}),
1312                "find" => serde_json::json!({"text":"needle"}),
1313                "click" | "dblclick" | "hover" | "scroll" => {
1314                    serde_json::json!({"target":"body"})
1315                }
1316                "fill" => serde_json::json!({"target":"body","text":"value"}),
1317                "select" => serde_json::json!({"target":"select","value":"A"}),
1318                "key" | "key_down" | "key_up" => serde_json::json!({"key":"Enter"}),
1319                "mouse_down" | "mouse_up" => {
1320                    serde_json::json!({"x":10.0,"y":20.0,"button":"left"})
1321                }
1322                "mouse_move" => serde_json::json!({"x":10.0,"y":20.0}),
1323                "eval" | "exec" => serde_json::json!({"code":"return 1"}),
1324                "frame_eval" => serde_json::json!({"frame":"iframe","code":"1+1"}),
1325                "frame_click" => serde_json::json!({"frame":"iframe","target":"button"}),
1326                "frame_fill" => {
1327                    serde_json::json!({"frame":"iframe","target":"input","text":"value"})
1328                }
1329                "emulate" => serde_json::json!({"width":390,"height":844}),
1330                "download_start" => serde_json::json!({"click":"@e1","output":"/tmp/download"}),
1331                "download_get" | "download_cancel" | "download_wait" => {
1332                    serde_json::json!({"id":"job"})
1333                }
1334                "fetch" => serde_json::json!({"url":"https://example.com"}),
1335                "set_cookie" => serde_json::json!({"name":"a","value":"b"}),
1336                "delete_cookie" => serde_json::json!({"name":"a"}),
1337                "storage" => serde_json::json!({"key":"a"}),
1338                "set_storage" => serde_json::json!({"key":"a","value":"b"}),
1339                "save_state" | "load_state" | "network_har" | "network_export" => {
1340                    serde_json::json!({"path":"/tmp/eoka-state.json"})
1341                }
1342                "headers" => serde_json::json!({"headers_json":"{}"}),
1343                "console" | "errors" => serde_json::json!({"clear":false}),
1344                "tab_new" => serde_json::json!({"url":"about:blank"}),
1345                "tab_switch" | "tab_close" | "tab_attach" => serde_json::json!({"tab_id":"1"}),
1346                "wait" => serde_json::json!({"text":"ready"}),
1347                "spa_navigate" => serde_json::json!({"path":"/"}),
1348                "fake_camera" => serde_json::json!({"file":"/tmp/video.y4m"}),
1349                "wasm_read" => serde_json::json!({"addr":"0","len":1}),
1350                "wasm_write" => serde_json::json!({"addr":"0","hex":"00"}),
1351                "wasm_find" => serde_json::json!({"pattern":"00"}),
1352                "network_record_start" => serde_json::json!({"patterns":["*"]}),
1353                "network_log" => serde_json::json!({"limit":10}),
1354                "network_show" => serde_json::json!({"id":1}),
1355                "intercept_remove" => serde_json::json!({"id":"1"}),
1356                "network_wait" => serde_json::json!({"pattern":"*"}),
1357                "intercept_add" => serde_json::json!({"url_pattern":"*"}),
1358                "intercept_log" => serde_json::json!({"clear":false}),
1359                "js_mode" => serde_json::json!({"mode":"block"}),
1360                "js_allow" | "js_block" | "js_remove" => {
1361                    serde_json::json!({"domain":"example.com"})
1362                }
1363                "clone_from" => serde_json::json!({"source":"9222"}),
1364                "captcha_solve" => serde_json::json!({
1365                    "api_key":"key",
1366                    "captcha_type":"hcaptcha",
1367                    "website_url":"https://example.com",
1368                    "website_key":"site"
1369                }),
1370                "captcha_detect" => serde_json::json!({}),
1371                "captcha_inject" => serde_json::json!({"token":"token"}),
1372                _ => serde_json::json!({}),
1373            }
1374        }
1375    }
1376}