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