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