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}