Skip to main content

eoka_protocol/
lib.rs

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