Skip to main content

eoka_protocol/
lib.rs

1use serde::{Deserialize, Serialize};
2use tokio::io::{AsyncReadExt, AsyncWriteExt};
3
4#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5pub enum OperationExposure {
6    DefaultAgent,
7    OptIn,
8    Lifecycle,
9}
10
11impl OperationExposure {
12    pub fn as_str(self) -> &'static str {
13        match self {
14            Self::DefaultAgent => "defaultAgent",
15            Self::OptIn => "optIn",
16            Self::Lifecycle => "lifecycle",
17        }
18    }
19}
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum OperationCapability {
23    Navigation,
24    Observation,
25    Interaction,
26    JavaScript,
27    BrowserState,
28    Tabs,
29    Spa,
30    Wasm,
31    Network,
32    Policy,
33    Media,
34    Captcha,
35    Lifecycle,
36}
37
38impl OperationCapability {
39    pub fn as_str(self) -> &'static str {
40        match self {
41            Self::Navigation => "navigation",
42            Self::Observation => "observation",
43            Self::Interaction => "interaction",
44            Self::JavaScript => "javascript",
45            Self::BrowserState => "browser-state",
46            Self::Tabs => "tabs",
47            Self::Spa => "spa",
48            Self::Wasm => "wasm",
49            Self::Network => "network",
50            Self::Policy => "policy",
51            Self::Media => "media",
52            Self::Captcha => "captcha",
53            Self::Lifecycle => "lifecycle",
54        }
55    }
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize)]
59#[serde(rename_all = "camelCase")]
60pub struct ToolManifestEntry {
61    pub path: String,
62    pub cmd: &'static str,
63    pub name: &'static str,
64    pub description: &'static str,
65    pub capability: &'static str,
66    pub exposure: &'static str,
67    pub read_only: bool,
68    pub destructive: bool,
69    pub input_schema: serde_json::Value,
70    pub tags: Vec<&'static str>,
71}
72
73macro_rules! define_operations {
74    (
75        $(
76            $variant:ident {
77                path: $path:literal,
78                cmd: $cmd:literal,
79                name: $name:literal,
80                description: $description:literal,
81                capability: $capability:ident,
82                exposure: $exposure:ident,
83                read_only: $read_only:literal,
84                destructive: $destructive:literal
85                $(, input: $input:ty)?
86                $(,)?
87            }
88        ),+ $(,)?
89    ) => {
90        #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
91        pub enum OperationId {
92            $($variant),+
93        }
94
95        #[derive(Debug, Clone, Copy)]
96        pub struct OperationSpec {
97            pub id: OperationId,
98            pub path: &'static str,
99            pub cmd: &'static str,
100            pub name: &'static str,
101            pub description: &'static str,
102            pub capability: OperationCapability,
103            pub exposure: OperationExposure,
104            pub read_only: bool,
105            pub destructive: bool,
106        }
107
108        #[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
109        #[serde(tag = "cmd", content = "args")]
110        pub enum Request {
111            $(
112                #[serde(rename = $cmd)]
113                $variant$(($input))?,
114            )+
115        }
116
117        pub const OPERATIONS: &[OperationSpec] = &[
118            $(OperationSpec {
119                id: OperationId::$variant,
120                path: $path,
121                cmd: $cmd,
122                name: $name,
123                description: $description,
124                capability: OperationCapability::$capability,
125                exposure: OperationExposure::$exposure,
126                read_only: $read_only,
127                destructive: $destructive,
128            }),+
129        ];
130        pub fn all_operations() -> &'static [OperationSpec] {
131            OPERATIONS
132        }
133
134        pub fn operation_by_path(path: &str) -> Option<&'static OperationSpec> {
135            OPERATIONS.iter().find(|operation| operation.path == path)
136        }
137
138        pub fn operation_by_cmd(cmd: &str) -> Option<&'static OperationSpec> {
139            OPERATIONS.iter().find(|operation| operation.cmd == cmd)
140        }
141
142        pub fn default_agent_operations() -> impl Iterator<Item = &'static OperationSpec> {
143            OPERATIONS
144                .iter()
145                .filter(|operation| operation.exposure == OperationExposure::DefaultAgent)
146        }
147
148        pub fn exposed_operations(
149            include_opt_in: bool,
150        ) -> impl Iterator<Item = &'static OperationSpec> {
151            OPERATIONS.iter().filter(move |operation| {
152                operation.exposure == OperationExposure::DefaultAgent
153                    || (include_opt_in && operation.exposure == OperationExposure::OptIn)
154            })
155        }
156
157        pub fn request_from_operation_path(
158            path: &str,
159            input: serde_json::Value,
160        ) -> Result<Request, String> {
161            let operation = operation_by_path(path)
162                .ok_or_else(|| format!("unknown eoka operation path: {path}"))?;
163            request_from_cmd(operation.cmd, input)
164        }
165
166        pub fn request_from_cmd(cmd: &str, input: serde_json::Value) -> Result<Request, String> {
167            match cmd {
168                $(
169                    $cmd => Ok(define_operations!(@request Request::$variant, input $(, $input)?)),
170                )+
171                _ => Err(format!("unknown eoka protocol command: {cmd}")),
172            }
173        }
174
175        pub fn input_schema_for_cmd(cmd: &str) -> serde_json::Value {
176            match cmd {
177                $(
178                    $cmd => define_operations!(@schema $( $input )?),
179                )+
180                _ => serde_json::json!({ "type": "object", "additionalProperties": false }),
181            }
182        }
183
184        pub fn input_schema_for_operation(operation: &OperationSpec) -> serde_json::Value {
185            input_schema_for_cmd(operation.cmd)
186        }
187
188        pub fn tags_for_operation(operation: &OperationSpec) -> Vec<&'static str> {
189            vec!["eoka", operation.capability.as_str()]
190        }
191
192        pub fn manifest_entry_for_operation(
193            namespace: &str,
194            operation: &OperationSpec,
195        ) -> ToolManifestEntry {
196            ToolManifestEntry {
197                path: format!("{}.{}", namespace, operation.path),
198                cmd: operation.cmd,
199                name: operation.name,
200                description: operation.description,
201                capability: operation.capability.as_str(),
202                exposure: operation.exposure.as_str(),
203                read_only: operation.read_only,
204                destructive: operation.destructive,
205                input_schema: input_schema_for_operation(operation),
206                tags: tags_for_operation(operation),
207            }
208        }
209
210        pub fn manifest_for_operations(
211            namespace: &str,
212            include_opt_in: bool,
213        ) -> Vec<ToolManifestEntry> {
214            exposed_operations(include_opt_in)
215                .map(|operation| manifest_entry_for_operation(namespace, operation))
216                .collect()
217        }
218
219        impl Request {
220            pub fn cmd(&self) -> &'static str {
221                match self {
222                    $(
223                        define_operations!(@match_variant Self::$variant $(, $input)?) => $cmd,
224                    )+
225                }
226            }
227
228            pub fn args_json(&self) -> serde_json::Value {
229                serde_json::to_value(self)
230                    .ok()
231                    .and_then(|value| value.get("args").cloned())
232                    .unwrap_or_else(|| serde_json::json!({}))
233            }
234        }
235    };
236
237    (@request $variant:path, $input:ident, $ty:ty) => {{
238        serde_json::from_value::<$ty>($input)
239            .map($variant)
240            .map_err(|error| error.to_string())?
241    }};
242
243    (@request $variant:path, $input:ident) => {{
244        if $input != serde_json::json!({}) && $input != serde_json::Value::Null {
245            return Err("expected empty object for zero-argument command".to_string());
246        }
247        $variant
248    }};
249
250    (@schema $ty:ty) => {
251        schema_for::<$ty>()
252    };
253
254    (@schema) => {
255        serde_json::json!({ "type": "object", "additionalProperties": false })
256    };
257
258    (@match_variant $variant:path, $ty:ty) => {
259        $variant(_)
260    };
261
262    (@match_variant $variant:path) => {
263        $variant
264    };
265
266}
267
268define_operations! {
269    Open {
270        path: "open",
271        cmd: "open",
272        name: "open",
273        description: "Navigate to URL",
274        capability: Navigation,
275        exposure: DefaultAgent,
276        read_only: false,
277        destructive: false,
278        input: OpenArgs,
279    },
280    Back {
281        path: "back",
282        cmd: "back",
283        name: "back",
284        description: "Go back",
285        capability: Navigation,
286        exposure: DefaultAgent,
287        read_only: false,
288        destructive: false,
289    },
290    Forward {
291        path: "forward",
292        cmd: "forward",
293        name: "forward",
294        description: "Go forward",
295        capability: Navigation,
296        exposure: DefaultAgent,
297        read_only: false,
298        destructive: false,
299    },
300    Reload {
301        path: "reload",
302        cmd: "reload",
303        name: "reload",
304        description: "Reload page",
305        capability: Navigation,
306        exposure: DefaultAgent,
307        read_only: false,
308        destructive: false,
309    },
310    Snapshot {
311        path: "snapshot",
312        cmd: "snapshot",
313        name: "snapshot",
314        description: "Accessibility snapshot",
315        capability: Observation,
316        exposure: DefaultAgent,
317        read_only: true,
318        destructive: false,
319        input: SnapshotArgs,
320    },
321    Observe {
322        path: "observe",
323        cmd: "observe",
324        name: "observe",
325        description: "Observe interactive elements",
326        capability: Observation,
327        exposure: DefaultAgent,
328        read_only: true,
329        destructive: false,
330        input: ObserveArgs,
331    },
332    Screenshot {
333        path: "screenshot",
334        cmd: "screenshot",
335        name: "screenshot",
336        description: "Take screenshot",
337        capability: Observation,
338        exposure: DefaultAgent,
339        read_only: true,
340        destructive: false,
341        input: ScreenshotArgs,
342    },
343    Emulate {
344        path: "emulate",
345        cmd: "emulate",
346        name: "emulate",
347        description: "Emulate viewport",
348        capability: Navigation,
349        exposure: DefaultAgent,
350        read_only: false,
351        destructive: false,
352        input: EmulateArgs,
353    },
354    Info {
355        path: "info",
356        cmd: "info",
357        name: "info",
358        description: "Page URL and title",
359        capability: Observation,
360        exposure: DefaultAgent,
361        read_only: true,
362        destructive: false,
363    },
364    Text {
365        path: "text",
366        cmd: "text",
367        name: "text",
368        description: "Visible page text",
369        capability: Observation,
370        exposure: DefaultAgent,
371        read_only: true,
372        destructive: false,
373    },
374    Find {
375        path: "find",
376        cmd: "find",
377        name: "find",
378        description: "Find elements by text",
379        capability: Observation,
380        exposure: DefaultAgent,
381        read_only: true,
382        destructive: false,
383        input: TextArgs,
384    },
385    Click {
386        path: "click",
387        cmd: "click",
388        name: "click",
389        description: "Click target",
390        capability: Interaction,
391        exposure: DefaultAgent,
392        read_only: false,
393        destructive: true,
394        input: TargetArgs,
395    },
396    DblClick {
397        path: "double_click",
398        cmd: "dblclick",
399        name: "double_click",
400        description: "Double click target",
401        capability: Interaction,
402        exposure: DefaultAgent,
403        read_only: false,
404        destructive: true,
405        input: TargetArgs,
406    },
407    Fill {
408        path: "fill",
409        cmd: "fill",
410        name: "fill",
411        description: "Fill input",
412        capability: Interaction,
413        exposure: DefaultAgent,
414        read_only: false,
415        destructive: true,
416        input: FillArgs,
417    },
418    Select {
419        path: "select",
420        cmd: "select",
421        name: "select",
422        description: "Select option",
423        capability: Interaction,
424        exposure: DefaultAgent,
425        read_only: false,
426        destructive: true,
427        input: SelectArgs,
428    },
429    Hover {
430        path: "hover",
431        cmd: "hover",
432        name: "hover",
433        description: "Hover target",
434        capability: Interaction,
435        exposure: DefaultAgent,
436        read_only: false,
437        destructive: false,
438        input: TargetArgs,
439    },
440    Key {
441        path: "key",
442        cmd: "key",
443        name: "key",
444        description: "Press key",
445        capability: Interaction,
446        exposure: DefaultAgent,
447        read_only: false,
448        destructive: true,
449        input: KeyArgs,
450    },
451    Scroll {
452        path: "scroll",
453        cmd: "scroll",
454        name: "scroll",
455        description: "Scroll page or target",
456        capability: Interaction,
457        exposure: DefaultAgent,
458        read_only: false,
459        destructive: false,
460        input: TargetArgs,
461    },
462    Eval {
463        path: "eval",
464        cmd: "eval",
465        name: "eval",
466        description: "Evaluate JavaScript",
467        capability: JavaScript,
468        exposure: DefaultAgent,
469        read_only: false,
470        destructive: false,
471        input: ScriptArgs,
472    },
473    Exec {
474        path: "exec",
475        cmd: "exec",
476        name: "exec",
477        description: "Execute JavaScript",
478        capability: JavaScript,
479        exposure: DefaultAgent,
480        read_only: false,
481        destructive: true,
482        input: ScriptArgs,
483    },
484    Fetch {
485        path: "fetch",
486        cmd: "fetch",
487        name: "fetch",
488        description: "Fetch URL in page context",
489        capability: Network,
490        exposure: DefaultAgent,
491        read_only: false,
492        destructive: false,
493        input: FetchArgs,
494    },
495    Cookies {
496        path: "cookies",
497        cmd: "cookies",
498        name: "cookies",
499        description: "List cookies",
500        capability: BrowserState,
501        exposure: DefaultAgent,
502        read_only: true,
503        destructive: false,
504    },
505    SetCookie {
506        path: "set_cookie",
507        cmd: "set_cookie",
508        name: "set_cookie",
509        description: "Set cookie",
510        capability: BrowserState,
511        exposure: DefaultAgent,
512        read_only: false,
513        destructive: true,
514        input: SetCookieArgs,
515    },
516    DeleteCookie {
517        path: "delete_cookie",
518        cmd: "delete_cookie",
519        name: "delete_cookie",
520        description: "Delete cookie",
521        capability: BrowserState,
522        exposure: DefaultAgent,
523        read_only: false,
524        destructive: true,
525        input: DeleteCookieArgs,
526    },
527    ClearCookies {
528        path: "clear_cookies",
529        cmd: "clear_cookies",
530        name: "clear_cookies",
531        description: "Clear cookies",
532        capability: BrowserState,
533        exposure: DefaultAgent,
534        read_only: false,
535        destructive: true,
536    },
537    Storage {
538        path: "storage",
539        cmd: "storage",
540        name: "storage",
541        description: "Read storage",
542        capability: BrowserState,
543        exposure: DefaultAgent,
544        read_only: true,
545        destructive: false,
546        input: StorageArgs,
547    },
548    SetStorage {
549        path: "set_storage",
550        cmd: "set_storage",
551        name: "set_storage",
552        description: "Set storage",
553        capability: BrowserState,
554        exposure: DefaultAgent,
555        read_only: false,
556        destructive: true,
557        input: SetStorageArgs,
558    },
559    DumpStorage {
560        path: "dump_storage",
561        cmd: "dump_storage",
562        name: "dump_storage",
563        description: "Dump storage",
564        capability: BrowserState,
565        exposure: DefaultAgent,
566        read_only: true,
567        destructive: false,
568    },
569    SaveState {
570        path: "save_state",
571        cmd: "save_state",
572        name: "save_state",
573        description: "Save browser state",
574        capability: BrowserState,
575        exposure: DefaultAgent,
576        read_only: true,
577        destructive: false,
578        input: PathArgs,
579    },
580    LoadState {
581        path: "load_state",
582        cmd: "load_state",
583        name: "load_state",
584        description: "Load browser state",
585        capability: BrowserState,
586        exposure: DefaultAgent,
587        read_only: false,
588        destructive: true,
589        input: LoadStateArgs,
590    },
591    Headers {
592        path: "headers",
593        cmd: "headers",
594        name: "headers",
595        description: "Set extra headers",
596        capability: Network,
597        exposure: DefaultAgent,
598        read_only: false,
599        destructive: false,
600        input: HeadersArgs,
601    },
602    Console {
603        path: "console",
604        cmd: "console",
605        name: "console",
606        description: "Read console output",
607        capability: Observation,
608        exposure: DefaultAgent,
609        read_only: true,
610        destructive: false,
611        input: ConsoleArgs,
612    },
613    Errors {
614        path: "errors",
615        cmd: "errors",
616        name: "errors",
617        description: "Read JavaScript errors",
618        capability: Observation,
619        exposure: DefaultAgent,
620        read_only: true,
621        destructive: false,
622        input: ClearFlagArgs,
623    },
624    TabList {
625        path: "tab.list",
626        cmd: "tab_list",
627        name: "tab.list",
628        description: "List tabs",
629        capability: Tabs,
630        exposure: DefaultAgent,
631        read_only: true,
632        destructive: false,
633    },
634    TabNew {
635        path: "tab.new",
636        cmd: "tab_new",
637        name: "tab.new",
638        description: "Open new tab",
639        capability: Tabs,
640        exposure: DefaultAgent,
641        read_only: false,
642        destructive: false,
643        input: TabNewArgs,
644    },
645    TabSwitch {
646        path: "tab.switch",
647        cmd: "tab_switch",
648        name: "tab.switch",
649        description: "Switch tab",
650        capability: Tabs,
651        exposure: DefaultAgent,
652        read_only: false,
653        destructive: false,
654        input: TabIdArgs,
655    },
656    TabClose {
657        path: "tab.close",
658        cmd: "tab_close",
659        name: "tab.close",
660        description: "Close tab",
661        capability: Tabs,
662        exposure: DefaultAgent,
663        read_only: false,
664        destructive: true,
665        input: TabIdArgs,
666    },
667    TabAttach {
668        path: "tab.attach",
669        cmd: "tab_attach",
670        name: "tab.attach",
671        description: "Attach tab",
672        capability: Tabs,
673        exposure: DefaultAgent,
674        read_only: false,
675        destructive: false,
676        input: TabIdArgs,
677    },
678    CloneFrom {
679        path: "clone_from",
680        cmd: "clone_from",
681        name: "clone_from",
682        description: "Clone browser state",
683        capability: BrowserState,
684        exposure: DefaultAgent,
685        read_only: false,
686        destructive: true,
687        input: CloneFromArgs,
688    },
689    Wait {
690        path: "wait",
691        cmd: "wait",
692        name: "wait",
693        description: "Wait for page condition",
694        capability: Observation,
695        exposure: DefaultAgent,
696        read_only: true,
697        destructive: false,
698        input: WaitArgs,
699    },
700    SpaInfo {
701        path: "spa.info",
702        cmd: "spa_info",
703        name: "spa.info",
704        description: "SPA routing info",
705        capability: Spa,
706        exposure: DefaultAgent,
707        read_only: true,
708        destructive: false,
709    },
710    SpaNavigate {
711        path: "spa.navigate",
712        cmd: "spa_navigate",
713        name: "spa.navigate",
714        description: "SPA navigation",
715        capability: Spa,
716        exposure: DefaultAgent,
717        read_only: false,
718        destructive: false,
719        input: PathStringArgs,
720    },
721    FakeCamera {
722        path: "fake_camera",
723        cmd: "fake_camera",
724        name: "fake_camera",
725        description: "Inject fake camera",
726        capability: Media,
727        exposure: DefaultAgent,
728        read_only: false,
729        destructive: true,
730        input: FakeCameraArgs,
731    },
732    WasmInfo {
733        path: "wasm.info",
734        cmd: "wasm_info",
735        name: "wasm.info",
736        description: "WASM memory info",
737        capability: Wasm,
738        exposure: DefaultAgent,
739        read_only: true,
740        destructive: false,
741    },
742    WasmRead {
743        path: "wasm.read",
744        cmd: "wasm_read",
745        name: "wasm.read",
746        description: "Read WASM memory",
747        capability: Wasm,
748        exposure: DefaultAgent,
749        read_only: true,
750        destructive: false,
751        input: WasmReadArgs,
752    },
753    WasmWrite {
754        path: "wasm.write",
755        cmd: "wasm_write",
756        name: "wasm.write",
757        description: "Write WASM memory",
758        capability: Wasm,
759        exposure: DefaultAgent,
760        read_only: false,
761        destructive: true,
762        input: WasmWriteArgs,
763    },
764    WasmFind {
765        path: "wasm.find",
766        cmd: "wasm_find",
767        name: "wasm.find",
768        description: "Find WASM memory pattern",
769        capability: Wasm,
770        exposure: DefaultAgent,
771        read_only: true,
772        destructive: false,
773        input: WasmFindArgs,
774    },
775    InterceptAdd {
776        path: "intercept.add",
777        cmd: "intercept_add",
778        name: "intercept.add",
779        description: "Add network interception rule",
780        capability: Network,
781        exposure: OptIn,
782        read_only: false,
783        destructive: true,
784        input: InterceptAddArgs,
785    },
786    InterceptList {
787        path: "intercept.list",
788        cmd: "intercept_list",
789        name: "intercept.list",
790        description: "List network interception rules",
791        capability: Network,
792        exposure: OptIn,
793        read_only: true,
794        destructive: false,
795    },
796    InterceptRemove {
797        path: "intercept.remove",
798        cmd: "intercept_remove",
799        name: "intercept.remove",
800        description: "Remove network interception rule",
801        capability: Network,
802        exposure: OptIn,
803        read_only: false,
804        destructive: true,
805        input: IdArgs,
806    },
807    InterceptLog {
808        path: "intercept.log",
809        cmd: "intercept_log",
810        name: "intercept.log",
811        description: "Read intercepted request log",
812        capability: Network,
813        exposure: OptIn,
814        read_only: true,
815        destructive: false,
816        input: ClearFlagArgs,
817    },
818    JsMode {
819        path: "js.mode",
820        cmd: "js_mode",
821        name: "js.mode",
822        description: "Set JavaScript policy mode",
823        capability: Policy,
824        exposure: DefaultAgent,
825        read_only: false,
826        destructive: false,
827        input: ModeArgs,
828    },
829    JsAllow {
830        path: "js.allow",
831        cmd: "js_allow",
832        name: "js.allow",
833        description: "Allow JavaScript domain",
834        capability: Policy,
835        exposure: DefaultAgent,
836        read_only: false,
837        destructive: false,
838        input: DomainArgs,
839    },
840    JsBlock {
841        path: "js.block",
842        cmd: "js_block",
843        name: "js.block",
844        description: "Block JavaScript domain",
845        capability: Policy,
846        exposure: DefaultAgent,
847        read_only: false,
848        destructive: false,
849        input: DomainArgs,
850    },
851    JsRemove {
852        path: "js.remove",
853        cmd: "js_remove",
854        name: "js.remove",
855        description: "Remove JavaScript domain rule",
856        capability: Policy,
857        exposure: DefaultAgent,
858        read_only: false,
859        destructive: true,
860        input: DomainArgs,
861    },
862    JsList {
863        path: "js.list",
864        cmd: "js_list",
865        name: "js.list",
866        description: "List JavaScript policy",
867        capability: Policy,
868        exposure: DefaultAgent,
869        read_only: true,
870        destructive: false,
871    },
872    NetworkRecordStart {
873        path: "network.record.start",
874        cmd: "network_record_start",
875        name: "network.record.start",
876        description: "Start network recording",
877        capability: Network,
878        exposure: OptIn,
879        read_only: false,
880        destructive: false,
881        input: NetworkRecordStartArgs,
882    },
883    NetworkRecordStop {
884        path: "network.record.stop",
885        cmd: "network_record_stop",
886        name: "network.record.stop",
887        description: "Stop network recording",
888        capability: Network,
889        exposure: OptIn,
890        read_only: false,
891        destructive: false,
892    },
893    NetworkRecordStatus {
894        path: "network.record.status",
895        cmd: "network_record_status",
896        name: "network.record.status",
897        description: "Network recorder status",
898        capability: Network,
899        exposure: OptIn,
900        read_only: true,
901        destructive: false,
902    },
903    NetworkLog {
904        path: "network.log",
905        cmd: "network_log",
906        name: "network.log",
907        description: "Read network log",
908        capability: Network,
909        exposure: OptIn,
910        read_only: true,
911        destructive: false,
912        input: NetworkLogArgs,
913    },
914    NetworkShow {
915        path: "network.show",
916        cmd: "network_show",
917        name: "network.show",
918        description: "Show network entry details",
919        capability: Network,
920        exposure: OptIn,
921        read_only: true,
922        destructive: false,
923        input: NetworkShowArgs,
924    },
925    NetworkWait {
926        path: "network.wait",
927        cmd: "network_wait",
928        name: "network.wait",
929        description: "Wait for a matching network request",
930        capability: Network,
931        exposure: OptIn,
932        read_only: true,
933        destructive: false,
934        input: NetworkWaitArgs,
935    },
936    NetworkSaveHar {
937        path: "network.save_har",
938        cmd: "network_save_har",
939        name: "network.save_har",
940        description: "Save network traffic as HAR",
941        capability: Network,
942        exposure: OptIn,
943        read_only: true,
944        destructive: false,
945        input: NetworkExportArgs,
946    },
947    NetworkExport {
948        path: "network.export",
949        cmd: "network_export",
950        name: "network.export",
951        description: "Export network traffic",
952        capability: Network,
953        exposure: OptIn,
954        read_only: true,
955        destructive: false,
956        input: NetworkExportArgs,
957    },
958    NetworkClear {
959        path: "network.clear",
960        cmd: "network_clear",
961        name: "network.clear",
962        description: "Clear network log",
963        capability: Network,
964        exposure: OptIn,
965        read_only: false,
966        destructive: true,
967    },
968    Close {
969        path: "close",
970        cmd: "close",
971        name: "close",
972        description: "Close browser session",
973        capability: Lifecycle,
974        exposure: Lifecycle,
975        read_only: false,
976        destructive: true,
977    },
978    Shutdown {
979        path: "shutdown",
980        cmd: "shutdown",
981        name: "shutdown",
982        description: "Shut down daemon",
983        capability: Lifecycle,
984        exposure: Lifecycle,
985        read_only: false,
986        destructive: true,
987    },
988    CaptchaInject {
989        path: "captcha.inject",
990        cmd: "captcha_inject",
991        name: "captcha.inject",
992        description: "Inject a solved CAPTCHA token",
993        capability: Captcha,
994        exposure: OptIn,
995        read_only: false,
996        destructive: true,
997        input: CaptchaInjectArgs,
998    },
999}
1000
1001fn schema_for<T: schemars::JsonSchema>() -> serde_json::Value {
1002    serde_json::to_value(schemars::schema_for!(T))
1003        .unwrap_or_else(|_| serde_json::json!({ "type": "object", "additionalProperties": true }))
1004}
1005
1006#[derive(Debug, Clone, Serialize, Deserialize, Default, schemars::JsonSchema)]
1007pub struct OpenArgs {
1008    pub url: String,
1009    pub headers: Option<serde_json::Value>,
1010    pub user_agent: Option<String>,
1011    #[serde(default)]
1012    pub bypass_csp: bool,
1013    pub inject_js: Option<String>,
1014    pub load_state: Option<String>,
1015}
1016
1017#[derive(Debug, Clone, Serialize, Deserialize, Default, schemars::JsonSchema)]
1018pub struct SnapshotArgs {
1019    #[serde(default)]
1020    pub interactive: bool,
1021    #[serde(default)]
1022    pub all: bool,
1023}
1024
1025#[derive(Debug, Clone, Serialize, Deserialize, Default, schemars::JsonSchema)]
1026pub struct ObserveArgs {
1027    #[schemars(
1028        description = "Filter: 'inputs' for form elements, 'buttons' for buttons and links, or 'all' for all observed elements."
1029    )]
1030    pub filter: Option<String>,
1031    #[schemars(description = "Maximum elements to return.")]
1032    pub max: Option<usize>,
1033}
1034
1035#[derive(Debug, Clone, Serialize, Deserialize, Default, schemars::JsonSchema)]
1036pub struct ScreenshotArgs {
1037    pub output: Option<String>,
1038    #[serde(default)]
1039    pub annotate: bool,
1040}
1041
1042#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
1043pub struct EmulateArgs {
1044    #[serde(default = "default_viewport_width")]
1045    pub width: u32,
1046    #[serde(default = "default_viewport_height")]
1047    pub height: u32,
1048    #[serde(default = "default_device_pixel_ratio")]
1049    pub dpr: f64,
1050    #[serde(default)]
1051    pub desktop: bool,
1052    #[serde(default)]
1053    pub reset: bool,
1054}
1055
1056impl Default for EmulateArgs {
1057    fn default() -> Self {
1058        Self {
1059            width: default_viewport_width(),
1060            height: default_viewport_height(),
1061            dpr: default_device_pixel_ratio(),
1062            desktop: false,
1063            reset: false,
1064        }
1065    }
1066}
1067
1068#[derive(Debug, Clone, Serialize, Deserialize, Default, schemars::JsonSchema)]
1069pub struct TextArgs {
1070    #[schemars(description = "Text substring to search for, matched case-insensitively.")]
1071    pub text: String,
1072}
1073
1074#[derive(Debug, Clone, Serialize, Deserialize, Default, schemars::JsonSchema)]
1075pub struct TargetArgs {
1076    #[schemars(
1077        description = "Target element. Supports index, snapshot refs like @e1, text:Submit, placeholder:Email, role:button, css:form button, id:my-btn, or plain text search."
1078    )]
1079    pub target: String,
1080}
1081
1082#[derive(Debug, Clone, Serialize, Deserialize, Default, schemars::JsonSchema)]
1083pub struct FillArgs {
1084    #[schemars(
1085        description = "Target input. Supports index, text:Email, placeholder:Enter code, css:input.search, id:email-field, or plain text search."
1086    )]
1087    pub target: String,
1088    #[schemars(description = "Text to type into the element.")]
1089    pub text: String,
1090}
1091
1092#[derive(Debug, Clone, Serialize, Deserialize, Default, schemars::JsonSchema)]
1093pub struct SelectArgs {
1094    #[schemars(description = "Target select element by index, text, CSS selector, id, or role.")]
1095    pub target: String,
1096    #[schemars(description = "Option value or visible text to select.")]
1097    pub value: String,
1098}
1099
1100#[derive(Debug, Clone, Serialize, Deserialize, Default, schemars::JsonSchema)]
1101pub struct KeyArgs {
1102    #[schemars(
1103        description = "Key to press, for example Enter, Tab, Escape, ArrowDown, or Backspace."
1104    )]
1105    pub key: String,
1106}
1107
1108#[derive(Debug, Clone, Serialize, Deserialize, Default, schemars::JsonSchema)]
1109pub struct ScriptArgs {
1110    pub code: Option<String>,
1111    pub file: Option<String>,
1112    pub max_size: Option<usize>,
1113    #[serde(default)]
1114    pub no_await: bool,
1115}
1116
1117#[derive(Debug, Clone, Serialize, Deserialize, Default, schemars::JsonSchema)]
1118pub struct FetchArgs {
1119    pub url: String,
1120    pub method: Option<String>,
1121    pub headers: Option<serde_json::Value>,
1122    pub body: Option<String>,
1123    pub redirect: Option<String>,
1124    #[serde(default)]
1125    pub body_only: bool,
1126    pub max_body: Option<usize>,
1127}
1128
1129#[derive(Debug, Clone, Serialize, Deserialize, Default, schemars::JsonSchema)]
1130pub struct SetCookieArgs {
1131    pub name: String,
1132    pub value: String,
1133    pub domain: Option<String>,
1134    pub path: Option<String>,
1135}
1136
1137#[derive(Debug, Clone, Serialize, Deserialize, Default, schemars::JsonSchema)]
1138pub struct DeleteCookieArgs {
1139    pub name: String,
1140    pub domain: Option<String>,
1141}
1142
1143#[derive(Debug, Clone, Serialize, Deserialize, Default, schemars::JsonSchema)]
1144pub struct StorageArgs {
1145    pub key: Option<String>,
1146    #[serde(default)]
1147    pub session_storage: bool,
1148}
1149
1150#[derive(Debug, Clone, Serialize, Deserialize, Default, schemars::JsonSchema)]
1151pub struct SetStorageArgs {
1152    pub key: String,
1153    pub value: String,
1154    #[serde(default)]
1155    pub session_storage: bool,
1156}
1157
1158#[derive(Debug, Clone, Serialize, Deserialize, Default, schemars::JsonSchema)]
1159pub struct PathArgs {
1160    #[schemars(description = "File path.")]
1161    pub path: String,
1162}
1163
1164#[derive(Debug, Clone, Serialize, Deserialize, Default, schemars::JsonSchema)]
1165pub struct LoadStateArgs {
1166    pub path: String,
1167    #[serde(default)]
1168    pub no_navigate: bool,
1169}
1170
1171#[derive(Debug, Clone, Serialize, Deserialize, Default, schemars::JsonSchema)]
1172pub struct HeadersArgs {
1173    pub headers_json: String,
1174}
1175
1176#[derive(Debug, Clone, Serialize, Deserialize, Default, schemars::JsonSchema)]
1177pub struct ConsoleArgs {
1178    #[serde(default)]
1179    pub clear: bool,
1180    pub level: Option<String>,
1181}
1182
1183#[derive(Debug, Clone, Serialize, Deserialize, Default, schemars::JsonSchema)]
1184pub struct ClearFlagArgs {
1185    #[serde(default)]
1186    pub clear: bool,
1187}
1188
1189#[derive(Debug, Clone, Serialize, Deserialize, Default, schemars::JsonSchema)]
1190pub struct TabNewArgs {
1191    #[schemars(description = "Optional URL to navigate to. If omitted, opens a blank tab.")]
1192    pub url: Option<String>,
1193}
1194
1195#[derive(Debug, Clone, Serialize, Deserialize, Default, schemars::JsonSchema)]
1196pub struct TabIdArgs {
1197    #[schemars(description = "Tab ID from list_tabs.")]
1198    pub tab_id: String,
1199}
1200
1201#[derive(Debug, Clone, Serialize, Deserialize, Default, schemars::JsonSchema)]
1202pub struct CloneFromArgs {
1203    pub source: String,
1204    pub to: Option<String>,
1205}
1206
1207#[derive(Debug, Clone, Serialize, Deserialize, Default, schemars::JsonSchema)]
1208pub struct WaitArgs {
1209    pub ms: Option<u64>,
1210    pub text: Option<String>,
1211    pub url: Option<String>,
1212    pub load: Option<String>,
1213    pub timeout: Option<u64>,
1214}
1215
1216#[derive(Debug, Clone, Serialize, Deserialize, Default, schemars::JsonSchema)]
1217pub struct PathStringArgs {
1218    #[schemars(description = "Target path, for example /docs or /about.")]
1219    pub path: String,
1220}
1221
1222#[derive(Debug, Clone, Serialize, Deserialize, Default, schemars::JsonSchema)]
1223pub struct FakeCameraArgs {
1224    pub file: String,
1225    #[serde(default)]
1226    pub loop_video: bool,
1227}
1228
1229#[derive(Debug, Clone, Serialize, Deserialize, Default, schemars::JsonSchema)]
1230pub struct WasmReadArgs {
1231    pub addr: String,
1232    pub len: usize,
1233    pub memory: Option<String>,
1234}
1235
1236#[derive(Debug, Clone, Serialize, Deserialize, Default, schemars::JsonSchema)]
1237pub struct WasmWriteArgs {
1238    pub addr: String,
1239    pub hex: String,
1240    pub memory: Option<String>,
1241}
1242
1243#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
1244pub struct WasmFindArgs {
1245    pub pattern: String,
1246    pub start: Option<String>,
1247    pub end: Option<String>,
1248    #[serde(default = "default_wasm_find_max")]
1249    pub max: usize,
1250    pub memory: Option<String>,
1251}
1252
1253impl Default for WasmFindArgs {
1254    fn default() -> Self {
1255        Self {
1256            pattern: String::new(),
1257            start: None,
1258            end: None,
1259            max: default_wasm_find_max(),
1260            memory: None,
1261        }
1262    }
1263}
1264
1265#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
1266pub struct InterceptAddArgs {
1267    pub url_pattern: String,
1268    pub capture: Option<String>,
1269    pub respond: Option<String>,
1270    #[serde(default = "default_intercept_status")]
1271    pub status: u16,
1272}
1273
1274impl Default for InterceptAddArgs {
1275    fn default() -> Self {
1276        Self {
1277            url_pattern: String::new(),
1278            capture: None,
1279            respond: None,
1280            status: default_intercept_status(),
1281        }
1282    }
1283}
1284
1285#[derive(Debug, Clone, Serialize, Deserialize, Default, schemars::JsonSchema)]
1286pub struct IdArgs {
1287    pub id: String,
1288}
1289
1290#[derive(Debug, Clone, Serialize, Deserialize, Default, schemars::JsonSchema)]
1291pub struct ModeArgs {
1292    pub mode: String,
1293}
1294
1295#[derive(Debug, Clone, Serialize, Deserialize, Default, schemars::JsonSchema)]
1296pub struct DomainArgs {
1297    pub domain: String,
1298}
1299
1300#[derive(Debug, Clone, Serialize, Deserialize, Default, schemars::JsonSchema)]
1301pub struct CaptchaInjectArgs {
1302    pub token: String,
1303    #[serde(default = "default_captcha_type")]
1304    pub captcha_type: String,
1305    pub callback: Option<String>,
1306    pub click_after: Option<String>,
1307}
1308
1309#[derive(Debug, Clone, Serialize, Deserialize, Default, schemars::JsonSchema)]
1310pub struct NetworkRecordStartArgs {
1311    #[serde(default)]
1312    pub patterns: Vec<String>,
1313    #[serde(default)]
1314    pub no_bodies: bool,
1315    #[serde(default = "default_max_body_bytes")]
1316    pub max_body_bytes: usize,
1317    #[serde(default)]
1318    pub clear: bool,
1319}
1320
1321#[derive(Debug, Clone, Serialize, Deserialize, Default, schemars::JsonSchema)]
1322pub struct NetworkLogArgs {
1323    pub limit: Option<usize>,
1324    pub pattern: Option<String>,
1325    pub method: Option<String>,
1326    pub status: Option<u16>,
1327    pub since: Option<u64>,
1328    #[serde(default)]
1329    pub compact: bool,
1330}
1331
1332#[derive(Debug, Clone, Serialize, Deserialize, Default, schemars::JsonSchema)]
1333pub struct NetworkShowArgs {
1334    pub id: u64,
1335    #[serde(default)]
1336    pub body: bool,
1337    pub max_body: Option<usize>,
1338}
1339
1340#[derive(Debug, Clone, Serialize, Deserialize, Default, schemars::JsonSchema)]
1341pub struct NetworkWaitArgs {
1342    pub pattern: Option<String>,
1343    pub method: Option<String>,
1344    pub status: Option<u16>,
1345    pub timeout: Option<u64>,
1346    pub since: Option<u64>,
1347    #[serde(default)]
1348    pub include_existing: bool,
1349}
1350
1351#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
1352pub struct NetworkExportArgs {
1353    pub path: String,
1354    #[serde(default = "default_network_export_format")]
1355    pub format: String,
1356    pub settle_ms: Option<u64>,
1357}
1358
1359impl Default for NetworkExportArgs {
1360    fn default() -> Self {
1361        Self {
1362            path: String::new(),
1363            format: default_network_export_format(),
1364            settle_ms: None,
1365        }
1366    }
1367}
1368
1369fn default_max_body_bytes() -> usize {
1370    10 * 1024 * 1024
1371}
1372
1373fn default_network_export_format() -> String {
1374    "har".to_string()
1375}
1376
1377fn default_viewport_width() -> u32 {
1378    390
1379}
1380
1381fn default_viewport_height() -> u32 {
1382    844
1383}
1384
1385fn default_device_pixel_ratio() -> f64 {
1386    2.0
1387}
1388
1389fn default_wasm_find_max() -> usize {
1390    20
1391}
1392
1393fn default_intercept_status() -> u16 {
1394    200
1395}
1396
1397fn default_captcha_type() -> String {
1398    "auto".to_string()
1399}
1400
1401#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
1402pub struct Response {
1403    pub ok: bool,
1404    #[serde(skip_serializing_if = "Option::is_none")]
1405    pub data: Option<serde_json::Value>,
1406    #[serde(skip_serializing_if = "Option::is_none")]
1407    pub error: Option<String>,
1408}
1409
1410impl Response {
1411    pub fn ok(data: impl Into<serde_json::Value>) -> Self {
1412        Self {
1413            ok: true,
1414            data: Some(data.into()),
1415            error: None,
1416        }
1417    }
1418
1419    pub fn ok_text(msg: impl Into<String>) -> Self {
1420        Self::ok(serde_json::Value::String(msg.into()))
1421    }
1422
1423    pub fn err(msg: impl Into<String>) -> Self {
1424        Self {
1425            ok: false,
1426            data: None,
1427            error: Some(msg.into()),
1428        }
1429    }
1430}
1431pub async fn write_msg<W: AsyncWriteExt + Unpin>(
1432    writer: &mut W,
1433    msg: &impl Serialize,
1434) -> std::io::Result<()> {
1435    let payload = serde_json::to_vec(msg).map_err(|e| std::io::Error::other(e.to_string()))?;
1436    let len = (payload.len() as u32).to_be_bytes();
1437    writer.write_all(&len).await?;
1438    writer.write_all(&payload).await?;
1439    writer.flush().await?;
1440    Ok(())
1441}
1442pub async fn read_msg<R: AsyncReadExt + Unpin, T: for<'de> Deserialize<'de>>(
1443    reader: &mut R,
1444) -> std::io::Result<T> {
1445    let mut len_buf = [0u8; 4];
1446    reader.read_exact(&mut len_buf).await?;
1447    let len = u32::from_be_bytes(len_buf) as usize;
1448
1449    if len > 64 * 1024 * 1024 {
1450        return Err(std::io::Error::other("message too large (>64MB)"));
1451    }
1452
1453    let mut buf = vec![0u8; len];
1454    reader.read_exact(&mut buf).await?;
1455    serde_json::from_slice(&buf).map_err(|e| std::io::Error::other(e.to_string()))
1456}
1457
1458#[cfg(test)]
1459mod tests {
1460    use super::*;
1461    use tokio::net::{UnixListener, UnixStream};
1462
1463    fn temp_socket_path(name: &str) -> std::path::PathBuf {
1464        std::env::temp_dir().join(format!(
1465            "eoka-protocol-test-{}-{}.sock",
1466            std::process::id(),
1467            name
1468        ))
1469    }
1470
1471    #[tokio::test]
1472    async fn round_trip_over_unix_socket() {
1473        let sock_path = temp_socket_path("roundtrip");
1474        let _ = std::fs::remove_file(&sock_path);
1475        let listener = UnixListener::bind(&sock_path).unwrap();
1476
1477        let server = tokio::spawn(async move {
1478            let (stream, _) = listener.accept().await.unwrap();
1479            let (mut reader, mut writer) = stream.into_split();
1480            let req: Request = read_msg(&mut reader).await.unwrap();
1481            assert_eq!(req.cmd(), "click");
1482            assert_eq!(req.args_json(), serde_json::json!({ "target": "@e1" }));
1483            write_msg(&mut writer, &Response::ok_text("pong"))
1484                .await
1485                .unwrap();
1486        });
1487
1488        let stream = UnixStream::connect(&sock_path).await.unwrap();
1489        let (mut reader, mut writer) = stream.into_split();
1490        write_msg(
1491            &mut writer,
1492            &Request::Click(TargetArgs {
1493                target: "@e1".into(),
1494            }),
1495        )
1496        .await
1497        .unwrap();
1498        let response: Response = read_msg(&mut reader).await.unwrap();
1499
1500        server.await.unwrap();
1501        let _ = std::fs::remove_file(&sock_path);
1502
1503        assert!(response.ok);
1504        assert_eq!(
1505            response.data,
1506            Some(serde_json::Value::String("pong".into()))
1507        );
1508        assert_eq!(response.error, None);
1509    }
1510
1511    #[tokio::test]
1512    async fn read_msg_rejects_oversized_length_prefix() {
1513        let sock_path = temp_socket_path("oversized");
1514        let _ = std::fs::remove_file(&sock_path);
1515        let listener = UnixListener::bind(&sock_path).unwrap();
1516
1517        let server = tokio::spawn(async move {
1518            let (stream, _) = listener.accept().await.unwrap();
1519            let (mut reader, _writer) = stream.into_split();
1520            let result: std::io::Result<Request> = read_msg(&mut reader).await;
1521            assert!(result.is_err());
1522            assert!(result.unwrap_err().to_string().contains("too large"));
1523        });
1524
1525        let mut stream = UnixStream::connect(&sock_path).await.unwrap();
1526        let oversized_len: u32 = 65 * 1024 * 1024;
1527        stream
1528            .write_all(&oversized_len.to_be_bytes())
1529            .await
1530            .unwrap();
1531
1532        server.await.unwrap();
1533        let _ = std::fs::remove_file(&sock_path);
1534    }
1535
1536    #[test]
1537    fn typed_network_request_round_trips_with_cmd_and_args() {
1538        let request = Request::NetworkWait(NetworkWaitArgs {
1539            pattern: Some("*/api/*".into()),
1540            method: Some("POST".into()),
1541            status: Some(201),
1542            timeout: Some(5000),
1543            since: Some(10),
1544            include_existing: false,
1545        });
1546
1547        let value = serde_json::to_value(&request).unwrap();
1548        assert_eq!(value["cmd"], "network_wait");
1549        assert_eq!(value["args"]["pattern"], "*/api/*");
1550
1551        let parsed: Request = serde_json::from_value(value).unwrap();
1552        assert_eq!(parsed.cmd(), "network_wait");
1553        assert_eq!(parsed.args_json()["status"], 201);
1554    }
1555
1556    #[test]
1557    fn typed_request_rejects_unknown_command() {
1558        let result: Result<Request, _> =
1559            serde_json::from_value(serde_json::json!({ "cmd": "not_real", "args": {} }));
1560
1561        assert!(result.is_err());
1562    }
1563
1564    #[test]
1565    fn legacy_network_save_har_request_is_still_accepted() {
1566        let request: Request = serde_json::from_value(serde_json::json!({
1567            "cmd": "network_save_har",
1568            "args": { "path": "/tmp/session.har" }
1569        }))
1570        .unwrap();
1571
1572        assert_eq!(request.cmd(), "network_save_har");
1573        assert_eq!(request.args_json()["format"], "har");
1574    }
1575
1576    #[test]
1577    fn operation_catalog_maps_stable_paths_to_protocol_commands() {
1578        let tab = operation_by_path("tab.new").unwrap();
1579        assert_eq!(tab.cmd, "tab_new");
1580        assert_eq!(tab.exposure, OperationExposure::DefaultAgent);
1581
1582        let request = request_from_operation_path(
1583            "tab.new",
1584            serde_json::json!({"url":"https://example.com"}),
1585        )
1586        .unwrap();
1587        assert_eq!(request.cmd(), "tab_new");
1588        assert_eq!(request.args_json()["url"], "https://example.com");
1589    }
1590
1591    #[test]
1592    fn default_catalog_excludes_opt_in_and_lifecycle_operations() {
1593        let paths: Vec<&str> = default_agent_operations()
1594            .map(|operation| operation.path)
1595            .collect();
1596
1597        assert!(paths.contains(&"open"));
1598        assert!(paths.contains(&"tab.list"));
1599        assert!(!paths.contains(&"network.log"));
1600        assert!(!paths.contains(&"close"));
1601    }
1602
1603    #[test]
1604    fn operation_catalog_has_unique_paths_and_commands() {
1605        let mut paths = std::collections::BTreeSet::new();
1606        let mut commands = std::collections::BTreeSet::new();
1607        let mut ids = std::collections::BTreeSet::new();
1608
1609        for operation in all_operations() {
1610            assert!(ids.insert(operation.id), "duplicate id {:?}", operation.id);
1611            assert!(
1612                paths.insert(operation.path),
1613                "duplicate path {}",
1614                operation.path
1615            );
1616            assert!(
1617                commands.insert(operation.cmd),
1618                "duplicate cmd {}",
1619                operation.cmd
1620            );
1621        }
1622    }
1623
1624    #[test]
1625    fn manifest_uses_catalog_metadata_and_schemas() {
1626        let default_manifest = manifest_for_operations("eoka", false);
1627        let paths: std::collections::BTreeSet<&str> = default_manifest
1628            .iter()
1629            .map(|entry| entry.path.as_str())
1630            .collect();
1631
1632        assert!(paths.contains("eoka.open"));
1633        assert!(paths.contains("eoka.tab.list"));
1634        assert!(!paths.contains("eoka.network.log"));
1635        assert!(!paths.contains("eoka.close"));
1636
1637        let open = default_manifest
1638            .iter()
1639            .find(|entry| entry.path == "eoka.open")
1640            .unwrap();
1641        assert_eq!(open.capability, "navigation");
1642        assert_eq!(open.exposure, "defaultAgent");
1643        assert_eq!(open.tags, vec!["eoka", "navigation"]);
1644        assert!(open.input_schema.get("properties").is_some());
1645
1646        let all_manifest = manifest_for_operations("eoka", true);
1647        let all_paths: std::collections::BTreeSet<&str> = all_manifest
1648            .iter()
1649            .map(|entry| entry.path.as_str())
1650            .collect();
1651        assert!(all_paths.contains("eoka.network.log"));
1652        assert!(!all_paths.contains("eoka.close"));
1653    }
1654
1655    #[test]
1656    fn shared_mcp_inputs_keep_schema_descriptions() {
1657        let click_schema = input_schema_for_cmd("click");
1658        assert_eq!(
1659            click_schema["properties"]["target"]["description"],
1660            "Target element. Supports index, snapshot refs like @e1, text:Submit, placeholder:Email, role:button, css:form button, id:my-btn, or plain text search."
1661        );
1662
1663        let tab_schema = input_schema_for_cmd("tab_switch");
1664        assert_eq!(
1665            tab_schema["properties"]["tab_id"]["description"],
1666            "Tab ID from list_tabs."
1667        );
1668    }
1669}