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