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