Skip to main content

eoka_protocol/
lib.rs

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