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    FrameEval {
492        path: "frame_eval",
493        cmd: "frame_eval",
494        name: "frame_eval",
495        description: "Evaluate JavaScript inside an iframe",
496        capability: JavaScript,
497        exposure: DefaultAgent,
498        read_only: false,
499        destructive: false,
500        input: FrameEvalArgs,
501    },
502    Fetch {
503        path: "fetch",
504        cmd: "fetch",
505        name: "fetch",
506        description: "Fetch URL in page context",
507        capability: Network,
508        exposure: DefaultAgent,
509        read_only: false,
510        destructive: false,
511        input: FetchArgs,
512    },
513    Cookies {
514        path: "cookies",
515        cmd: "cookies",
516        name: "cookies",
517        description: "List cookies",
518        capability: BrowserState,
519        exposure: DefaultAgent,
520        read_only: true,
521        destructive: false,
522    },
523    SetCookie {
524        path: "set_cookie",
525        cmd: "set_cookie",
526        name: "set_cookie",
527        description: "Set cookie",
528        capability: BrowserState,
529        exposure: DefaultAgent,
530        read_only: false,
531        destructive: true,
532        input: SetCookieArgs,
533    },
534    DeleteCookie {
535        path: "delete_cookie",
536        cmd: "delete_cookie",
537        name: "delete_cookie",
538        description: "Delete cookie",
539        capability: BrowserState,
540        exposure: DefaultAgent,
541        read_only: false,
542        destructive: true,
543        input: DeleteCookieArgs,
544    },
545    ClearCookies {
546        path: "clear_cookies",
547        cmd: "clear_cookies",
548        name: "clear_cookies",
549        description: "Clear cookies",
550        capability: BrowserState,
551        exposure: DefaultAgent,
552        read_only: false,
553        destructive: true,
554    },
555    Storage {
556        path: "storage",
557        cmd: "storage",
558        name: "storage",
559        description: "Read storage",
560        capability: BrowserState,
561        exposure: DefaultAgent,
562        read_only: true,
563        destructive: false,
564        input: StorageArgs,
565    },
566    SetStorage {
567        path: "set_storage",
568        cmd: "set_storage",
569        name: "set_storage",
570        description: "Set storage",
571        capability: BrowserState,
572        exposure: DefaultAgent,
573        read_only: false,
574        destructive: true,
575        input: SetStorageArgs,
576    },
577    DumpStorage {
578        path: "dump_storage",
579        cmd: "dump_storage",
580        name: "dump_storage",
581        description: "Dump storage",
582        capability: BrowserState,
583        exposure: DefaultAgent,
584        read_only: true,
585        destructive: false,
586    },
587    SaveState {
588        path: "save_state",
589        cmd: "save_state",
590        name: "save_state",
591        description: "Save browser state",
592        capability: BrowserState,
593        exposure: DefaultAgent,
594        read_only: true,
595        destructive: false,
596        input: PathArgs,
597    },
598    LoadState {
599        path: "load_state",
600        cmd: "load_state",
601        name: "load_state",
602        description: "Load browser state",
603        capability: BrowserState,
604        exposure: DefaultAgent,
605        read_only: false,
606        destructive: true,
607        input: LoadStateArgs,
608    },
609    Headers {
610        path: "headers",
611        cmd: "headers",
612        name: "headers",
613        description: "Set extra headers",
614        capability: Network,
615        exposure: DefaultAgent,
616        read_only: false,
617        destructive: false,
618        input: HeadersArgs,
619    },
620    Console {
621        path: "console",
622        cmd: "console",
623        name: "console",
624        description: "Read console output",
625        capability: Observation,
626        exposure: DefaultAgent,
627        read_only: true,
628        destructive: false,
629        input: ConsoleArgs,
630    },
631    Errors {
632        path: "errors",
633        cmd: "errors",
634        name: "errors",
635        description: "Read JavaScript errors",
636        capability: Observation,
637        exposure: DefaultAgent,
638        read_only: true,
639        destructive: false,
640        input: ClearFlagArgs,
641    },
642    TabList {
643        path: "tab.list",
644        cmd: "tab_list",
645        name: "tab.list",
646        description: "List tabs",
647        capability: Tabs,
648        exposure: DefaultAgent,
649        read_only: true,
650        destructive: false,
651    },
652    TabNew {
653        path: "tab.new",
654        cmd: "tab_new",
655        name: "tab.new",
656        description: "Open new tab",
657        capability: Tabs,
658        exposure: DefaultAgent,
659        read_only: false,
660        destructive: false,
661        input: TabNewArgs,
662    },
663    TabSwitch {
664        path: "tab.switch",
665        cmd: "tab_switch",
666        name: "tab.switch",
667        description: "Switch tab",
668        capability: Tabs,
669        exposure: DefaultAgent,
670        read_only: false,
671        destructive: false,
672        input: TabIdArgs,
673    },
674    TabClose {
675        path: "tab.close",
676        cmd: "tab_close",
677        name: "tab.close",
678        description: "Close tab",
679        capability: Tabs,
680        exposure: DefaultAgent,
681        read_only: false,
682        destructive: true,
683        input: TabIdArgs,
684    },
685    TabAttach {
686        path: "tab.attach",
687        cmd: "tab_attach",
688        name: "tab.attach",
689        description: "Attach tab",
690        capability: Tabs,
691        exposure: DefaultAgent,
692        read_only: false,
693        destructive: false,
694        input: TabIdArgs,
695    },
696    CloneFrom {
697        path: "clone_from",
698        cmd: "clone_from",
699        name: "clone_from",
700        description: "Clone browser state",
701        capability: BrowserState,
702        exposure: DefaultAgent,
703        read_only: false,
704        destructive: true,
705        input: CloneFromArgs,
706    },
707    Wait {
708        path: "wait",
709        cmd: "wait",
710        name: "wait",
711        description: "Wait for page condition",
712        capability: Observation,
713        exposure: DefaultAgent,
714        read_only: true,
715        destructive: false,
716        input: WaitArgs,
717    },
718    SpaInfo {
719        path: "spa.info",
720        cmd: "spa_info",
721        name: "spa.info",
722        description: "SPA routing info",
723        capability: Spa,
724        exposure: DefaultAgent,
725        read_only: true,
726        destructive: false,
727    },
728    SpaNavigate {
729        path: "spa.navigate",
730        cmd: "spa_navigate",
731        name: "spa.navigate",
732        description: "SPA navigation",
733        capability: Spa,
734        exposure: DefaultAgent,
735        read_only: false,
736        destructive: false,
737        input: PathStringArgs,
738    },
739    FakeCamera {
740        path: "fake_camera",
741        cmd: "fake_camera",
742        name: "fake_camera",
743        description: "Inject fake camera",
744        capability: Media,
745        exposure: DefaultAgent,
746        read_only: false,
747        destructive: true,
748        input: FakeCameraArgs,
749    },
750    WasmInfo {
751        path: "wasm.info",
752        cmd: "wasm_info",
753        name: "wasm.info",
754        description: "WASM memory info",
755        capability: Wasm,
756        exposure: DefaultAgent,
757        read_only: true,
758        destructive: false,
759    },
760    WasmRead {
761        path: "wasm.read",
762        cmd: "wasm_read",
763        name: "wasm.read",
764        description: "Read WASM memory",
765        capability: Wasm,
766        exposure: DefaultAgent,
767        read_only: true,
768        destructive: false,
769        input: WasmReadArgs,
770    },
771    WasmWrite {
772        path: "wasm.write",
773        cmd: "wasm_write",
774        name: "wasm.write",
775        description: "Write WASM memory",
776        capability: Wasm,
777        exposure: DefaultAgent,
778        read_only: false,
779        destructive: true,
780        input: WasmWriteArgs,
781    },
782    WasmFind {
783        path: "wasm.find",
784        cmd: "wasm_find",
785        name: "wasm.find",
786        description: "Find WASM memory pattern",
787        capability: Wasm,
788        exposure: DefaultAgent,
789        read_only: true,
790        destructive: false,
791        input: WasmFindArgs,
792    },
793    InterceptAdd {
794        path: "intercept.add",
795        cmd: "intercept_add",
796        name: "intercept.add",
797        description: "Add network interception rule",
798        capability: Network,
799        exposure: OptIn,
800        read_only: false,
801        destructive: true,
802        input: InterceptAddArgs,
803    },
804    InterceptList {
805        path: "intercept.list",
806        cmd: "intercept_list",
807        name: "intercept.list",
808        description: "List network interception rules",
809        capability: Network,
810        exposure: OptIn,
811        read_only: true,
812        destructive: false,
813    },
814    InterceptRemove {
815        path: "intercept.remove",
816        cmd: "intercept_remove",
817        name: "intercept.remove",
818        description: "Remove network interception rule",
819        capability: Network,
820        exposure: OptIn,
821        read_only: false,
822        destructive: true,
823        input: IdArgs,
824    },
825    InterceptLog {
826        path: "intercept.log",
827        cmd: "intercept_log",
828        name: "intercept.log",
829        description: "Read intercepted request log",
830        capability: Network,
831        exposure: OptIn,
832        read_only: true,
833        destructive: false,
834        input: ClearFlagArgs,
835    },
836    JsMode {
837        path: "js.mode",
838        cmd: "js_mode",
839        name: "js.mode",
840        description: "Set JavaScript policy mode",
841        capability: Policy,
842        exposure: DefaultAgent,
843        read_only: false,
844        destructive: false,
845        input: ModeArgs,
846    },
847    JsAllow {
848        path: "js.allow",
849        cmd: "js_allow",
850        name: "js.allow",
851        description: "Allow JavaScript domain",
852        capability: Policy,
853        exposure: DefaultAgent,
854        read_only: false,
855        destructive: false,
856        input: DomainArgs,
857    },
858    JsBlock {
859        path: "js.block",
860        cmd: "js_block",
861        name: "js.block",
862        description: "Block JavaScript domain",
863        capability: Policy,
864        exposure: DefaultAgent,
865        read_only: false,
866        destructive: false,
867        input: DomainArgs,
868    },
869    JsRemove {
870        path: "js.remove",
871        cmd: "js_remove",
872        name: "js.remove",
873        description: "Remove JavaScript domain rule",
874        capability: Policy,
875        exposure: DefaultAgent,
876        read_only: false,
877        destructive: true,
878        input: DomainArgs,
879    },
880    JsList {
881        path: "js.list",
882        cmd: "js_list",
883        name: "js.list",
884        description: "List JavaScript policy",
885        capability: Policy,
886        exposure: DefaultAgent,
887        read_only: true,
888        destructive: false,
889    },
890    NetworkRecordStart {
891        path: "network.record.start",
892        cmd: "network_record_start",
893        name: "network.record.start",
894        description: "Start network recording",
895        capability: Network,
896        exposure: OptIn,
897        read_only: false,
898        destructive: false,
899        input: NetworkRecordStartArgs,
900    },
901    NetworkRecordStop {
902        path: "network.record.stop",
903        cmd: "network_record_stop",
904        name: "network.record.stop",
905        description: "Stop network recording",
906        capability: Network,
907        exposure: OptIn,
908        read_only: false,
909        destructive: false,
910    },
911    NetworkRecordStatus {
912        path: "network.record.status",
913        cmd: "network_record_status",
914        name: "network.record.status",
915        description: "Network recorder status",
916        capability: Network,
917        exposure: OptIn,
918        read_only: true,
919        destructive: false,
920    },
921    NetworkLog {
922        path: "network.log",
923        cmd: "network_log",
924        name: "network.log",
925        description: "Read network log",
926        capability: Network,
927        exposure: OptIn,
928        read_only: true,
929        destructive: false,
930        input: NetworkLogArgs,
931    },
932    NetworkShow {
933        path: "network.show",
934        cmd: "network_show",
935        name: "network.show",
936        description: "Show network entry details",
937        capability: Network,
938        exposure: OptIn,
939        read_only: true,
940        destructive: false,
941        input: NetworkShowArgs,
942    },
943    NetworkWait {
944        path: "network.wait",
945        cmd: "network_wait",
946        name: "network.wait",
947        description: "Wait for a matching network request",
948        capability: Network,
949        exposure: OptIn,
950        read_only: true,
951        destructive: false,
952        input: NetworkWaitArgs,
953    },
954    NetworkExport {
955        path: "network.export",
956        cmd: "network_export",
957        name: "network.export",
958        description: "Export network traffic",
959        capability: Network,
960        exposure: OptIn,
961        read_only: true,
962        destructive: false,
963        input: NetworkExportArgs,
964    },
965    NetworkClear {
966        path: "network.clear",
967        cmd: "network_clear",
968        name: "network.clear",
969        description: "Clear network log",
970        capability: Network,
971        exposure: OptIn,
972        read_only: false,
973        destructive: true,
974    },
975    Close {
976        path: "close",
977        cmd: "close",
978        name: "close",
979        description: "Close browser session",
980        capability: Lifecycle,
981        exposure: Lifecycle,
982        read_only: false,
983        destructive: true,
984    },
985    Shutdown {
986        path: "shutdown",
987        cmd: "shutdown",
988        name: "shutdown",
989        description: "Shut down daemon",
990        capability: Lifecycle,
991        exposure: Lifecycle,
992        read_only: false,
993        destructive: true,
994    },
995    CaptchaDatadome {
996        path: "captcha.datadome",
997        cmd: "captcha_datadome",
998        name: "captcha.datadome",
999        description: "Attempt local DataDome clearance on the existing current tab; not an authentication claim",
1000        capability: Captcha,
1001        exposure: OptIn,
1002        read_only: false,
1003        destructive: true,
1004        input: CaptchaDatadomeArgs,
1005    },
1006    CaptchaInject {
1007        path: "captcha.inject",
1008        cmd: "captcha_inject",
1009        name: "captcha.inject",
1010        description: "Inject a solved CAPTCHA token",
1011        capability: Captcha,
1012        exposure: OptIn,
1013        read_only: false,
1014        destructive: true,
1015        input: CaptchaInjectArgs,
1016    },
1017}
1018
1019fn schema_for<T: schemars::JsonSchema>() -> serde_json::Value {
1020    serde_json::to_value(schemars::schema_for!(T))
1021        .unwrap_or_else(|_| serde_json::json!({ "type": "object", "additionalProperties": true }))
1022}
1023
1024#[cfg(test)]
1025mod tests {
1026    use super::*;
1027    use tokio::io::AsyncWriteExt;
1028    use tokio::net::{UnixListener, UnixStream};
1029
1030    fn temp_socket_path(name: &str) -> std::path::PathBuf {
1031        std::env::temp_dir().join(format!(
1032            "eoka-protocol-test-{}-{}.sock",
1033            std::process::id(),
1034            name
1035        ))
1036    }
1037
1038    #[tokio::test]
1039    async fn round_trip_over_unix_socket() {
1040        let sock_path = temp_socket_path("roundtrip");
1041        let _ = std::fs::remove_file(&sock_path);
1042        let listener = UnixListener::bind(&sock_path).unwrap();
1043
1044        let server = tokio::spawn(async move {
1045            let (stream, _) = listener.accept().await.unwrap();
1046            let (mut reader, mut writer) = stream.into_split();
1047            let req: Request = read_msg(&mut reader).await.unwrap();
1048            assert_eq!(req.cmd(), "click");
1049            assert_eq!(req.args_json(), serde_json::json!({ "target": "@e1" }));
1050            write_msg(&mut writer, &Response::ok_text("pong"))
1051                .await
1052                .unwrap();
1053        });
1054
1055        let stream = UnixStream::connect(&sock_path).await.unwrap();
1056        let (mut reader, mut writer) = stream.into_split();
1057        write_msg(
1058            &mut writer,
1059            &Request::Click(TargetArgs {
1060                target: "@e1".into(),
1061            }),
1062        )
1063        .await
1064        .unwrap();
1065        let response: Response = read_msg(&mut reader).await.unwrap();
1066
1067        server.await.unwrap();
1068        let _ = std::fs::remove_file(&sock_path);
1069
1070        assert!(response.ok);
1071        assert_eq!(
1072            response.data,
1073            Some(serde_json::Value::String("pong".into()))
1074        );
1075        assert_eq!(response.error, None);
1076    }
1077
1078    #[tokio::test]
1079    async fn read_msg_rejects_oversized_length_prefix() {
1080        let sock_path = temp_socket_path("oversized");
1081        let _ = std::fs::remove_file(&sock_path);
1082        let listener = UnixListener::bind(&sock_path).unwrap();
1083
1084        let server = tokio::spawn(async move {
1085            let (stream, _) = listener.accept().await.unwrap();
1086            let (mut reader, _writer) = stream.into_split();
1087            let result: std::io::Result<Request> = read_msg(&mut reader).await;
1088            assert!(result.is_err());
1089            assert!(result.unwrap_err().to_string().contains("too large"));
1090        });
1091
1092        let mut stream = UnixStream::connect(&sock_path).await.unwrap();
1093        let oversized_len: u32 = 65 * 1024 * 1024;
1094        stream
1095            .write_all(&oversized_len.to_be_bytes())
1096            .await
1097            .unwrap();
1098
1099        server.await.unwrap();
1100        let _ = std::fs::remove_file(&sock_path);
1101    }
1102
1103    #[test]
1104    fn typed_network_request_round_trips_with_cmd_and_args() {
1105        let request = Request::NetworkWait(NetworkWaitArgs {
1106            pattern: Some("*/api/*".into()),
1107            method: Some("POST".into()),
1108            status: Some(201),
1109            timeout: Some(5000),
1110            since: Some(10),
1111            include_existing: false,
1112        });
1113
1114        let value = serde_json::to_value(&request).unwrap();
1115        assert_eq!(value["cmd"], "network_wait");
1116        assert_eq!(value["args"]["pattern"], "*/api/*");
1117
1118        let parsed: Request = serde_json::from_value(value).unwrap();
1119        assert_eq!(parsed.cmd(), "network_wait");
1120        assert_eq!(parsed.args_json()["status"], 201);
1121    }
1122
1123    #[test]
1124    fn typed_request_rejects_unknown_command() {
1125        let result: Result<Request, _> =
1126            serde_json::from_value(serde_json::json!({ "cmd": "not_real", "args": {} }));
1127
1128        assert!(result.is_err());
1129    }
1130
1131    #[test]
1132    fn operation_catalog_maps_stable_paths_to_protocol_commands() {
1133        let tab = operation_by_path("tab.new").unwrap();
1134        assert_eq!(tab.cmd, "tab_new");
1135        assert_eq!(tab.exposure, OperationExposure::DefaultAgent);
1136
1137        let request = request_from_operation_path(
1138            "tab.new",
1139            serde_json::json!({"url":"https://example.com"}),
1140        )
1141        .unwrap();
1142        assert_eq!(request.cmd(), "tab_new");
1143        assert_eq!(request.args_json()["url"], "https://example.com");
1144    }
1145
1146    #[test]
1147    fn default_catalog_excludes_opt_in_and_lifecycle_operations() {
1148        let paths: Vec<&str> = default_agent_operations()
1149            .map(|operation| operation.path)
1150            .collect();
1151
1152        assert!(paths.contains(&"open"));
1153        assert!(paths.contains(&"mouse.down"));
1154        assert!(paths.contains(&"key.down"));
1155        assert!(paths.contains(&"release_all_inputs"));
1156        assert!(paths.contains(&"tab.list"));
1157        assert!(!paths.contains(&"network.log"));
1158        assert!(!paths.contains(&"close"));
1159    }
1160
1161    #[test]
1162    fn operation_catalog_has_unique_paths_and_commands() {
1163        let mut paths = std::collections::BTreeSet::new();
1164        let mut commands = std::collections::BTreeSet::new();
1165        let mut ids = std::collections::BTreeSet::new();
1166
1167        for operation in all_operations() {
1168            assert!(ids.insert(operation.id), "duplicate id {:?}", operation.id);
1169            assert!(
1170                paths.insert(operation.path),
1171                "duplicate path {}",
1172                operation.path
1173            );
1174            assert!(
1175                commands.insert(operation.cmd),
1176                "duplicate cmd {}",
1177                operation.cmd
1178            );
1179            assert_eq!(operation_by_path(operation.path).unwrap().id, operation.id);
1180            assert_eq!(operation_by_cmd(operation.cmd).unwrap().id, operation.id);
1181            assert_eq!(
1182                request_from_operation_path(
1183                    operation.path,
1184                    operation.representative_input_for_test()
1185                )
1186                .unwrap()
1187                .cmd(),
1188                operation.cmd
1189            );
1190        }
1191    }
1192
1193    #[test]
1194    fn manifest_uses_catalog_metadata_and_schemas() {
1195        let default_manifest = manifest_for_operations("eoka", false);
1196        let paths: std::collections::BTreeSet<&str> = default_manifest
1197            .iter()
1198            .map(|entry| entry.path.as_str())
1199            .collect();
1200
1201        assert!(paths.contains("eoka.open"));
1202        assert!(paths.contains("eoka.mouse.down"));
1203        assert!(paths.contains("eoka.key.down"));
1204        assert!(paths.contains("eoka.release_all_inputs"));
1205        assert!(paths.contains("eoka.tab.list"));
1206        assert!(!paths.contains("eoka.network.log"));
1207        assert!(!paths.contains("eoka.close"));
1208
1209        let open = default_manifest
1210            .iter()
1211            .find(|entry| entry.path == "eoka.open")
1212            .unwrap();
1213        assert_eq!(open.capability, "navigation");
1214        assert_eq!(open.exposure, "defaultAgent");
1215        assert_eq!(open.tags, vec!["eoka", "navigation"]);
1216        assert!(open.input_schema.get("properties").is_some());
1217
1218        let all_manifest = manifest_for_operations("eoka", true);
1219        let all_paths: std::collections::BTreeSet<&str> = all_manifest
1220            .iter()
1221            .map(|entry| entry.path.as_str())
1222            .collect();
1223        assert!(all_paths.contains("eoka.network.log"));
1224        assert!(!all_paths.contains("eoka.close"));
1225    }
1226
1227    #[test]
1228    fn shared_mcp_inputs_keep_schema_descriptions() {
1229        let click_schema = input_schema_for_cmd("click");
1230        assert_eq!(
1231            click_schema["properties"]["target"]["description"],
1232            "Target element. Supports index, snapshot refs like @e1, text:Submit, placeholder:Email, role:button, css:form button, id:my-btn, or plain text search."
1233        );
1234
1235        let tab_schema = input_schema_for_cmd("tab_switch");
1236        assert_eq!(
1237            tab_schema["properties"]["tab_id"]["description"],
1238            "Tab ID from list_tabs."
1239        );
1240    }
1241
1242    impl OperationSpec {
1243        fn representative_input_for_test(&self) -> serde_json::Value {
1244            match self.cmd {
1245                "open" => serde_json::json!({"url":"about:blank"}),
1246                "find" => serde_json::json!({"text":"needle"}),
1247                "click" | "dblclick" | "hover" | "scroll" => {
1248                    serde_json::json!({"target":"body"})
1249                }
1250                "fill" => serde_json::json!({"target":"body","text":"value"}),
1251                "select" => serde_json::json!({"target":"select","value":"A"}),
1252                "key" | "key_down" | "key_up" => serde_json::json!({"key":"Enter"}),
1253                "mouse_down" | "mouse_up" => {
1254                    serde_json::json!({"x":10.0,"y":20.0,"button":"left"})
1255                }
1256                "mouse_move" => serde_json::json!({"x":10.0,"y":20.0}),
1257                "eval" | "exec" => serde_json::json!({"code":"return 1"}),
1258                "frame_eval" => serde_json::json!({"frame":"iframe","code":"return 1"}),
1259                "frame_click" => serde_json::json!({"frame":"iframe","target":"button"}),
1260                "frame_fill" => {
1261                    serde_json::json!({"frame":"iframe","target":"input","text":"value"})
1262                }
1263                "emulate" => serde_json::json!({"width":390,"height":844}),
1264                "fetch" => serde_json::json!({"url":"https://example.com"}),
1265                "set_cookie" => serde_json::json!({"name":"a","value":"b"}),
1266                "delete_cookie" => serde_json::json!({"name":"a"}),
1267                "storage" => serde_json::json!({"key":"a"}),
1268                "set_storage" => serde_json::json!({"key":"a","value":"b"}),
1269                "save_state" | "load_state" | "network_har" | "network_export" => {
1270                    serde_json::json!({"path":"/tmp/eoka-state.json"})
1271                }
1272                "headers" => serde_json::json!({"headers_json":"{}"}),
1273                "console" | "errors" => serde_json::json!({"clear":false}),
1274                "tab_new" => serde_json::json!({"url":"about:blank"}),
1275                "tab_switch" | "tab_close" | "tab_attach" => serde_json::json!({"tab_id":"1"}),
1276                "wait" => serde_json::json!({"text":"ready"}),
1277                "spa_navigate" => serde_json::json!({"path":"/"}),
1278                "fake_camera" => serde_json::json!({"file":"/tmp/video.y4m"}),
1279                "wasm_read" => serde_json::json!({"addr":"0","len":1}),
1280                "wasm_write" => serde_json::json!({"addr":"0","hex":"00"}),
1281                "wasm_find" => serde_json::json!({"pattern":"00"}),
1282                "network_record_start" => serde_json::json!({"patterns":["*"]}),
1283                "network_log" => serde_json::json!({"limit":10}),
1284                "network_show" => serde_json::json!({"id":1}),
1285                "intercept_remove" => serde_json::json!({"id":"1"}),
1286                "network_wait" => serde_json::json!({"pattern":"*"}),
1287                "intercept_add" => serde_json::json!({"url_pattern":"*"}),
1288                "intercept_log" => serde_json::json!({"clear":false}),
1289                "js_mode" => serde_json::json!({"mode":"block"}),
1290                "js_allow" | "js_block" | "js_remove" => {
1291                    serde_json::json!({"domain":"example.com"})
1292                }
1293                "clone_from" => serde_json::json!({"source":"9222"}),
1294                "captcha_solve" => serde_json::json!({
1295                    "api_key":"key",
1296                    "captcha_type":"hcaptcha",
1297                    "website_url":"https://example.com",
1298                    "website_key":"site"
1299                }),
1300                "captcha_detect" => serde_json::json!({}),
1301                "captcha_inject" => serde_json::json!({"token":"token"}),
1302                _ => serde_json::json!({}),
1303            }
1304        }
1305    }
1306}