Skip to main content

eoka_protocol/
lib.rs

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