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 Frames {
492 path: "frames",
493 cmd: "frames",
494 name: "frames",
495 description: "List the active page's frame IDs, URLs and names, including nested OOPIFs",
496 capability: Observation,
497 exposure: DefaultAgent,
498 read_only: true,
499 destructive: false,
500 },
501 FrameEval {
502 path: "frame_eval",
503 cmd: "frame_eval",
504 name: "frame_eval",
505 description: "Evaluate JavaScript in an isolated frame world by CSS selector or id:<frame-id>",
506 capability: JavaScript,
507 exposure: DefaultAgent,
508 read_only: false,
509 destructive: false,
510 input: FrameEvalArgs,
511 },
512 Fetch {
513 path: "fetch",
514 cmd: "fetch",
515 name: "fetch",
516 description: "Fetch URL in page context",
517 capability: Network,
518 exposure: DefaultAgent,
519 read_only: false,
520 destructive: false,
521 input: FetchArgs,
522 },
523 Cookies {
524 path: "cookies",
525 cmd: "cookies",
526 name: "cookies",
527 description: "List cookies",
528 capability: BrowserState,
529 exposure: DefaultAgent,
530 read_only: true,
531 destructive: false,
532 },
533 SetCookie {
534 path: "set_cookie",
535 cmd: "set_cookie",
536 name: "set_cookie",
537 description: "Set cookie",
538 capability: BrowserState,
539 exposure: DefaultAgent,
540 read_only: false,
541 destructive: true,
542 input: SetCookieArgs,
543 },
544 DeleteCookie {
545 path: "delete_cookie",
546 cmd: "delete_cookie",
547 name: "delete_cookie",
548 description: "Delete cookie",
549 capability: BrowserState,
550 exposure: DefaultAgent,
551 read_only: false,
552 destructive: true,
553 input: DeleteCookieArgs,
554 },
555 ClearCookies {
556 path: "clear_cookies",
557 cmd: "clear_cookies",
558 name: "clear_cookies",
559 description: "Clear cookies",
560 capability: BrowserState,
561 exposure: DefaultAgent,
562 read_only: false,
563 destructive: true,
564 },
565 Storage {
566 path: "storage",
567 cmd: "storage",
568 name: "storage",
569 description: "Read storage",
570 capability: BrowserState,
571 exposure: DefaultAgent,
572 read_only: true,
573 destructive: false,
574 input: StorageArgs,
575 },
576 SetStorage {
577 path: "set_storage",
578 cmd: "set_storage",
579 name: "set_storage",
580 description: "Set storage",
581 capability: BrowserState,
582 exposure: DefaultAgent,
583 read_only: false,
584 destructive: true,
585 input: SetStorageArgs,
586 },
587 DumpStorage {
588 path: "dump_storage",
589 cmd: "dump_storage",
590 name: "dump_storage",
591 description: "Dump storage",
592 capability: BrowserState,
593 exposure: DefaultAgent,
594 read_only: true,
595 destructive: false,
596 },
597 SaveState {
598 path: "save_state",
599 cmd: "save_state",
600 name: "save_state",
601 description: "Save browser state",
602 capability: BrowserState,
603 exposure: DefaultAgent,
604 read_only: true,
605 destructive: false,
606 input: PathArgs,
607 },
608 LoadState {
609 path: "load_state",
610 cmd: "load_state",
611 name: "load_state",
612 description: "Load browser state",
613 capability: BrowserState,
614 exposure: DefaultAgent,
615 read_only: false,
616 destructive: true,
617 input: LoadStateArgs,
618 },
619 Headers {
620 path: "headers",
621 cmd: "headers",
622 name: "headers",
623 description: "Set extra headers",
624 capability: Network,
625 exposure: DefaultAgent,
626 read_only: false,
627 destructive: false,
628 input: HeadersArgs,
629 },
630 Console {
631 path: "console",
632 cmd: "console",
633 name: "console",
634 description: "Read console output",
635 capability: Observation,
636 exposure: DefaultAgent,
637 read_only: true,
638 destructive: false,
639 input: ConsoleArgs,
640 },
641 Errors {
642 path: "errors",
643 cmd: "errors",
644 name: "errors",
645 description: "Read JavaScript errors",
646 capability: Observation,
647 exposure: DefaultAgent,
648 read_only: true,
649 destructive: false,
650 input: ClearFlagArgs,
651 },
652 TabList {
653 path: "tab.list",
654 cmd: "tab_list",
655 name: "tab.list",
656 description: "List tabs",
657 capability: Tabs,
658 exposure: DefaultAgent,
659 read_only: true,
660 destructive: false,
661 },
662 TabNew {
663 path: "tab.new",
664 cmd: "tab_new",
665 name: "tab.new",
666 description: "Open new tab",
667 capability: Tabs,
668 exposure: DefaultAgent,
669 read_only: false,
670 destructive: false,
671 input: TabNewArgs,
672 },
673 TabSwitch {
674 path: "tab.switch",
675 cmd: "tab_switch",
676 name: "tab.switch",
677 description: "Switch tab",
678 capability: Tabs,
679 exposure: DefaultAgent,
680 read_only: false,
681 destructive: false,
682 input: TabIdArgs,
683 },
684 TabClose {
685 path: "tab.close",
686 cmd: "tab_close",
687 name: "tab.close",
688 description: "Close tab",
689 capability: Tabs,
690 exposure: DefaultAgent,
691 read_only: false,
692 destructive: true,
693 input: TabIdArgs,
694 },
695 TabAttach {
696 path: "tab.attach",
697 cmd: "tab_attach",
698 name: "tab.attach",
699 description: "Attach tab",
700 capability: Tabs,
701 exposure: DefaultAgent,
702 read_only: false,
703 destructive: false,
704 input: TabIdArgs,
705 },
706 CloneFrom {
707 path: "clone_from",
708 cmd: "clone_from",
709 name: "clone_from",
710 description: "Clone browser state",
711 capability: BrowserState,
712 exposure: DefaultAgent,
713 read_only: false,
714 destructive: true,
715 input: CloneFromArgs,
716 },
717 Wait {
718 path: "wait",
719 cmd: "wait",
720 name: "wait",
721 description: "Wait for page condition",
722 capability: Observation,
723 exposure: DefaultAgent,
724 read_only: true,
725 destructive: false,
726 input: WaitArgs,
727 },
728 SpaInfo {
729 path: "spa.info",
730 cmd: "spa_info",
731 name: "spa.info",
732 description: "SPA routing info",
733 capability: Spa,
734 exposure: DefaultAgent,
735 read_only: true,
736 destructive: false,
737 },
738 SpaNavigate {
739 path: "spa.navigate",
740 cmd: "spa_navigate",
741 name: "spa.navigate",
742 description: "SPA navigation",
743 capability: Spa,
744 exposure: DefaultAgent,
745 read_only: false,
746 destructive: false,
747 input: PathStringArgs,
748 },
749 FakeCamera {
750 path: "fake_camera",
751 cmd: "fake_camera",
752 name: "fake_camera",
753 description: "Inject fake camera",
754 capability: Media,
755 exposure: DefaultAgent,
756 read_only: false,
757 destructive: true,
758 input: FakeCameraArgs,
759 },
760 WasmInfo {
761 path: "wasm.info",
762 cmd: "wasm_info",
763 name: "wasm.info",
764 description: "WASM memory info",
765 capability: Wasm,
766 exposure: DefaultAgent,
767 read_only: true,
768 destructive: false,
769 },
770 WasmRead {
771 path: "wasm.read",
772 cmd: "wasm_read",
773 name: "wasm.read",
774 description: "Read WASM memory",
775 capability: Wasm,
776 exposure: DefaultAgent,
777 read_only: true,
778 destructive: false,
779 input: WasmReadArgs,
780 },
781 WasmWrite {
782 path: "wasm.write",
783 cmd: "wasm_write",
784 name: "wasm.write",
785 description: "Write WASM memory",
786 capability: Wasm,
787 exposure: DefaultAgent,
788 read_only: false,
789 destructive: true,
790 input: WasmWriteArgs,
791 },
792 WasmFind {
793 path: "wasm.find",
794 cmd: "wasm_find",
795 name: "wasm.find",
796 description: "Find WASM memory pattern",
797 capability: Wasm,
798 exposure: DefaultAgent,
799 read_only: true,
800 destructive: false,
801 input: WasmFindArgs,
802 },
803 InterceptAdd {
804 path: "intercept.add",
805 cmd: "intercept_add",
806 name: "intercept.add",
807 description: "Add network interception rule",
808 capability: Network,
809 exposure: OptIn,
810 read_only: false,
811 destructive: true,
812 input: InterceptAddArgs,
813 },
814 InterceptList {
815 path: "intercept.list",
816 cmd: "intercept_list",
817 name: "intercept.list",
818 description: "List network interception rules",
819 capability: Network,
820 exposure: OptIn,
821 read_only: true,
822 destructive: false,
823 },
824 InterceptRemove {
825 path: "intercept.remove",
826 cmd: "intercept_remove",
827 name: "intercept.remove",
828 description: "Remove network interception rule",
829 capability: Network,
830 exposure: OptIn,
831 read_only: false,
832 destructive: true,
833 input: IdArgs,
834 },
835 InterceptLog {
836 path: "intercept.log",
837 cmd: "intercept_log",
838 name: "intercept.log",
839 description: "Read intercepted request log",
840 capability: Network,
841 exposure: OptIn,
842 read_only: true,
843 destructive: false,
844 input: ClearFlagArgs,
845 },
846 JsMode {
847 path: "js.mode",
848 cmd: "js_mode",
849 name: "js.mode",
850 description: "Set JavaScript policy mode",
851 capability: Policy,
852 exposure: DefaultAgent,
853 read_only: false,
854 destructive: false,
855 input: ModeArgs,
856 },
857 JsAllow {
858 path: "js.allow",
859 cmd: "js_allow",
860 name: "js.allow",
861 description: "Allow JavaScript domain",
862 capability: Policy,
863 exposure: DefaultAgent,
864 read_only: false,
865 destructive: false,
866 input: DomainArgs,
867 },
868 JsBlock {
869 path: "js.block",
870 cmd: "js_block",
871 name: "js.block",
872 description: "Block JavaScript domain",
873 capability: Policy,
874 exposure: DefaultAgent,
875 read_only: false,
876 destructive: false,
877 input: DomainArgs,
878 },
879 JsRemove {
880 path: "js.remove",
881 cmd: "js_remove",
882 name: "js.remove",
883 description: "Remove JavaScript domain rule",
884 capability: Policy,
885 exposure: DefaultAgent,
886 read_only: false,
887 destructive: true,
888 input: DomainArgs,
889 },
890 JsList {
891 path: "js.list",
892 cmd: "js_list",
893 name: "js.list",
894 description: "List JavaScript policy",
895 capability: Policy,
896 exposure: DefaultAgent,
897 read_only: true,
898 destructive: false,
899 },
900 NetworkRecordStart {
901 path: "network.record.start",
902 cmd: "network_record_start",
903 name: "network.record.start",
904 description: "Start network recording",
905 capability: Network,
906 exposure: OptIn,
907 read_only: false,
908 destructive: false,
909 input: NetworkRecordStartArgs,
910 },
911 NetworkRecordStop {
912 path: "network.record.stop",
913 cmd: "network_record_stop",
914 name: "network.record.stop",
915 description: "Stop network recording",
916 capability: Network,
917 exposure: OptIn,
918 read_only: false,
919 destructive: false,
920 },
921 NetworkRecordStatus {
922 path: "network.record.status",
923 cmd: "network_record_status",
924 name: "network.record.status",
925 description: "Network recorder status",
926 capability: Network,
927 exposure: OptIn,
928 read_only: true,
929 destructive: false,
930 },
931 NetworkLog {
932 path: "network.log",
933 cmd: "network_log",
934 name: "network.log",
935 description: "Read network log",
936 capability: Network,
937 exposure: OptIn,
938 read_only: true,
939 destructive: false,
940 input: NetworkLogArgs,
941 },
942 NetworkShow {
943 path: "network.show",
944 cmd: "network_show",
945 name: "network.show",
946 description: "Show network entry details",
947 capability: Network,
948 exposure: OptIn,
949 read_only: true,
950 destructive: false,
951 input: NetworkShowArgs,
952 },
953 NetworkWait {
954 path: "network.wait",
955 cmd: "network_wait",
956 name: "network.wait",
957 description: "Wait for a matching network request",
958 capability: Network,
959 exposure: OptIn,
960 read_only: true,
961 destructive: false,
962 input: NetworkWaitArgs,
963 },
964 NetworkExport {
965 path: "network.export",
966 cmd: "network_export",
967 name: "network.export",
968 description: "Export network traffic",
969 capability: Network,
970 exposure: OptIn,
971 read_only: true,
972 destructive: false,
973 input: NetworkExportArgs,
974 },
975 NetworkClear {
976 path: "network.clear",
977 cmd: "network_clear",
978 name: "network.clear",
979 description: "Clear network log",
980 capability: Network,
981 exposure: OptIn,
982 read_only: false,
983 destructive: true,
984 },
985 Close {
986 path: "close",
987 cmd: "close",
988 name: "close",
989 description: "Close browser session",
990 capability: Lifecycle,
991 exposure: Lifecycle,
992 read_only: false,
993 destructive: true,
994 },
995 Shutdown {
996 path: "shutdown",
997 cmd: "shutdown",
998 name: "shutdown",
999 description: "Shut down daemon",
1000 capability: Lifecycle,
1001 exposure: Lifecycle,
1002 read_only: false,
1003 destructive: true,
1004 },
1005 CaptchaDatadome {
1006 path: "captcha.datadome",
1007 cmd: "captcha_datadome",
1008 name: "captcha.datadome",
1009 description: "Attempt local DataDome clearance on the existing current tab; not an authentication claim",
1010 capability: Captcha,
1011 exposure: OptIn,
1012 read_only: false,
1013 destructive: true,
1014 input: CaptchaDatadomeArgs,
1015 },
1016 CaptchaInject {
1017 path: "captcha.inject",
1018 cmd: "captcha_inject",
1019 name: "captcha.inject",
1020 description: "Inject a solved CAPTCHA token",
1021 capability: Captcha,
1022 exposure: OptIn,
1023 read_only: false,
1024 destructive: true,
1025 input: CaptchaInjectArgs,
1026 },
1027}
1028
1029fn schema_for<T: schemars::JsonSchema>() -> serde_json::Value {
1030 serde_json::to_value(schemars::schema_for!(T))
1031 .unwrap_or_else(|_| serde_json::json!({ "type": "object", "additionalProperties": true }))
1032}
1033
1034#[cfg(test)]
1035mod tests {
1036 use super::*;
1037 use tokio::io::AsyncWriteExt;
1038 use tokio::net::{UnixListener, UnixStream};
1039
1040 fn temp_socket_path(name: &str) -> std::path::PathBuf {
1041 std::env::temp_dir().join(format!(
1042 "eoka-protocol-test-{}-{}.sock",
1043 std::process::id(),
1044 name
1045 ))
1046 }
1047
1048 #[tokio::test]
1049 async fn round_trip_over_unix_socket() {
1050 let sock_path = temp_socket_path("roundtrip");
1051 let _ = std::fs::remove_file(&sock_path);
1052 let listener = UnixListener::bind(&sock_path).unwrap();
1053
1054 let server = tokio::spawn(async move {
1055 let (stream, _) = listener.accept().await.unwrap();
1056 let (mut reader, mut writer) = stream.into_split();
1057 let req: Request = read_msg(&mut reader).await.unwrap();
1058 assert_eq!(req.cmd(), "click");
1059 assert_eq!(req.args_json(), serde_json::json!({ "target": "@e1" }));
1060 write_msg(&mut writer, &Response::ok_text("pong"))
1061 .await
1062 .unwrap();
1063 });
1064
1065 let stream = UnixStream::connect(&sock_path).await.unwrap();
1066 let (mut reader, mut writer) = stream.into_split();
1067 write_msg(
1068 &mut writer,
1069 &Request::Click(TargetArgs {
1070 target: "@e1".into(),
1071 }),
1072 )
1073 .await
1074 .unwrap();
1075 let response: Response = read_msg(&mut reader).await.unwrap();
1076
1077 server.await.unwrap();
1078 let _ = std::fs::remove_file(&sock_path);
1079
1080 assert!(response.ok);
1081 assert_eq!(
1082 response.data,
1083 Some(serde_json::Value::String("pong".into()))
1084 );
1085 assert_eq!(response.error, None);
1086 }
1087
1088 #[tokio::test]
1089 async fn read_msg_rejects_oversized_length_prefix() {
1090 let sock_path = temp_socket_path("oversized");
1091 let _ = std::fs::remove_file(&sock_path);
1092 let listener = UnixListener::bind(&sock_path).unwrap();
1093
1094 let server = tokio::spawn(async move {
1095 let (stream, _) = listener.accept().await.unwrap();
1096 let (mut reader, _writer) = stream.into_split();
1097 let result: std::io::Result<Request> = read_msg(&mut reader).await;
1098 assert!(result.is_err());
1099 assert!(result.unwrap_err().to_string().contains("too large"));
1100 });
1101
1102 let mut stream = UnixStream::connect(&sock_path).await.unwrap();
1103 let oversized_len: u32 = 65 * 1024 * 1024;
1104 stream
1105 .write_all(&oversized_len.to_be_bytes())
1106 .await
1107 .unwrap();
1108
1109 server.await.unwrap();
1110 let _ = std::fs::remove_file(&sock_path);
1111 }
1112
1113 #[test]
1114 fn typed_network_request_round_trips_with_cmd_and_args() {
1115 let request = Request::NetworkWait(NetworkWaitArgs {
1116 pattern: Some("*/api/*".into()),
1117 method: Some("POST".into()),
1118 status: Some(201),
1119 timeout: Some(5000),
1120 since: Some(10),
1121 include_existing: false,
1122 });
1123
1124 let value = serde_json::to_value(&request).unwrap();
1125 assert_eq!(value["cmd"], "network_wait");
1126 assert_eq!(value["args"]["pattern"], "*/api/*");
1127
1128 let parsed: Request = serde_json::from_value(value).unwrap();
1129 assert_eq!(parsed.cmd(), "network_wait");
1130 assert_eq!(parsed.args_json()["status"], 201);
1131 }
1132
1133 #[test]
1134 fn typed_request_rejects_unknown_command() {
1135 let result: Result<Request, _> =
1136 serde_json::from_value(serde_json::json!({ "cmd": "not_real", "args": {} }));
1137
1138 assert!(result.is_err());
1139 }
1140
1141 #[test]
1142 fn operation_catalog_maps_stable_paths_to_protocol_commands() {
1143 let tab = operation_by_path("tab.new").unwrap();
1144 assert_eq!(tab.cmd, "tab_new");
1145 assert_eq!(tab.exposure, OperationExposure::DefaultAgent);
1146
1147 let request = request_from_operation_path(
1148 "tab.new",
1149 serde_json::json!({"url":"https://example.com"}),
1150 )
1151 .unwrap();
1152 assert_eq!(request.cmd(), "tab_new");
1153 assert_eq!(request.args_json()["url"], "https://example.com");
1154 }
1155
1156 #[test]
1157 fn default_catalog_excludes_opt_in_and_lifecycle_operations() {
1158 let paths: Vec<&str> = default_agent_operations()
1159 .map(|operation| operation.path)
1160 .collect();
1161
1162 assert!(paths.contains(&"open"));
1163 assert!(paths.contains(&"mouse.down"));
1164 assert!(paths.contains(&"key.down"));
1165 assert!(paths.contains(&"release_all_inputs"));
1166 assert!(paths.contains(&"tab.list"));
1167 assert!(!paths.contains(&"network.log"));
1168 assert!(!paths.contains(&"close"));
1169 }
1170
1171 #[test]
1172 fn operation_catalog_has_unique_paths_and_commands() {
1173 let mut paths = std::collections::BTreeSet::new();
1174 let mut commands = std::collections::BTreeSet::new();
1175 let mut ids = std::collections::BTreeSet::new();
1176
1177 for operation in all_operations() {
1178 assert!(ids.insert(operation.id), "duplicate id {:?}", operation.id);
1179 assert!(
1180 paths.insert(operation.path),
1181 "duplicate path {}",
1182 operation.path
1183 );
1184 assert!(
1185 commands.insert(operation.cmd),
1186 "duplicate cmd {}",
1187 operation.cmd
1188 );
1189 assert_eq!(operation_by_path(operation.path).unwrap().id, operation.id);
1190 assert_eq!(operation_by_cmd(operation.cmd).unwrap().id, operation.id);
1191 assert_eq!(
1192 request_from_operation_path(
1193 operation.path,
1194 operation.representative_input_for_test()
1195 )
1196 .unwrap()
1197 .cmd(),
1198 operation.cmd
1199 );
1200 }
1201 }
1202
1203 #[test]
1204 fn manifest_uses_catalog_metadata_and_schemas() {
1205 let default_manifest = manifest_for_operations("eoka", false);
1206 let paths: std::collections::BTreeSet<&str> = default_manifest
1207 .iter()
1208 .map(|entry| entry.path.as_str())
1209 .collect();
1210
1211 assert!(paths.contains("eoka.open"));
1212 assert!(paths.contains("eoka.mouse.down"));
1213 assert!(paths.contains("eoka.key.down"));
1214 assert!(paths.contains("eoka.release_all_inputs"));
1215 assert!(paths.contains("eoka.tab.list"));
1216 assert!(!paths.contains("eoka.network.log"));
1217 assert!(!paths.contains("eoka.close"));
1218
1219 let open = default_manifest
1220 .iter()
1221 .find(|entry| entry.path == "eoka.open")
1222 .unwrap();
1223 assert_eq!(open.capability, "navigation");
1224 assert_eq!(open.exposure, "defaultAgent");
1225 assert_eq!(open.tags, vec!["eoka", "navigation"]);
1226 assert!(open.input_schema.get("properties").is_some());
1227
1228 let all_manifest = manifest_for_operations("eoka", true);
1229 let all_paths: std::collections::BTreeSet<&str> = all_manifest
1230 .iter()
1231 .map(|entry| entry.path.as_str())
1232 .collect();
1233 assert!(all_paths.contains("eoka.network.log"));
1234 assert!(!all_paths.contains("eoka.close"));
1235 }
1236
1237 #[test]
1238 fn shared_mcp_inputs_keep_schema_descriptions() {
1239 let click_schema = input_schema_for_cmd("click");
1240 assert_eq!(
1241 click_schema["properties"]["target"]["description"],
1242 "Target element. Supports index, snapshot refs like @e1, text:Submit, placeholder:Email, role:button, css:form button, id:my-btn, or plain text search."
1243 );
1244
1245 let tab_schema = input_schema_for_cmd("tab_switch");
1246 assert_eq!(
1247 tab_schema["properties"]["tab_id"]["description"],
1248 "Tab ID from list_tabs."
1249 );
1250 }
1251
1252 impl OperationSpec {
1253 fn representative_input_for_test(&self) -> serde_json::Value {
1254 match self.cmd {
1255 "open" => serde_json::json!({"url":"about:blank"}),
1256 "find" => serde_json::json!({"text":"needle"}),
1257 "click" | "dblclick" | "hover" | "scroll" => {
1258 serde_json::json!({"target":"body"})
1259 }
1260 "fill" => serde_json::json!({"target":"body","text":"value"}),
1261 "select" => serde_json::json!({"target":"select","value":"A"}),
1262 "key" | "key_down" | "key_up" => serde_json::json!({"key":"Enter"}),
1263 "mouse_down" | "mouse_up" => {
1264 serde_json::json!({"x":10.0,"y":20.0,"button":"left"})
1265 }
1266 "mouse_move" => serde_json::json!({"x":10.0,"y":20.0}),
1267 "eval" | "exec" => serde_json::json!({"code":"return 1"}),
1268 "frame_eval" => serde_json::json!({"frame":"iframe","code":"1+1"}),
1269 "frame_click" => serde_json::json!({"frame":"iframe","target":"button"}),
1270 "frame_fill" => {
1271 serde_json::json!({"frame":"iframe","target":"input","text":"value"})
1272 }
1273 "emulate" => serde_json::json!({"width":390,"height":844}),
1274 "fetch" => serde_json::json!({"url":"https://example.com"}),
1275 "set_cookie" => serde_json::json!({"name":"a","value":"b"}),
1276 "delete_cookie" => serde_json::json!({"name":"a"}),
1277 "storage" => serde_json::json!({"key":"a"}),
1278 "set_storage" => serde_json::json!({"key":"a","value":"b"}),
1279 "save_state" | "load_state" | "network_har" | "network_export" => {
1280 serde_json::json!({"path":"/tmp/eoka-state.json"})
1281 }
1282 "headers" => serde_json::json!({"headers_json":"{}"}),
1283 "console" | "errors" => serde_json::json!({"clear":false}),
1284 "tab_new" => serde_json::json!({"url":"about:blank"}),
1285 "tab_switch" | "tab_close" | "tab_attach" => serde_json::json!({"tab_id":"1"}),
1286 "wait" => serde_json::json!({"text":"ready"}),
1287 "spa_navigate" => serde_json::json!({"path":"/"}),
1288 "fake_camera" => serde_json::json!({"file":"/tmp/video.y4m"}),
1289 "wasm_read" => serde_json::json!({"addr":"0","len":1}),
1290 "wasm_write" => serde_json::json!({"addr":"0","hex":"00"}),
1291 "wasm_find" => serde_json::json!({"pattern":"00"}),
1292 "network_record_start" => serde_json::json!({"patterns":["*"]}),
1293 "network_log" => serde_json::json!({"limit":10}),
1294 "network_show" => serde_json::json!({"id":1}),
1295 "intercept_remove" => serde_json::json!({"id":"1"}),
1296 "network_wait" => serde_json::json!({"pattern":"*"}),
1297 "intercept_add" => serde_json::json!({"url_pattern":"*"}),
1298 "intercept_log" => serde_json::json!({"clear":false}),
1299 "js_mode" => serde_json::json!({"mode":"block"}),
1300 "js_allow" | "js_block" | "js_remove" => {
1301 serde_json::json!({"domain":"example.com"})
1302 }
1303 "clone_from" => serde_json::json!({"source":"9222"}),
1304 "captcha_solve" => serde_json::json!({
1305 "api_key":"key",
1306 "captcha_type":"hcaptcha",
1307 "website_url":"https://example.com",
1308 "website_key":"site"
1309 }),
1310 "captcha_detect" => serde_json::json!({}),
1311 "captcha_inject" => serde_json::json!({"token":"token"}),
1312 _ => serde_json::json!({}),
1313 }
1314 }
1315 }
1316}