Skip to main content

eoka_protocol/
lib.rs

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