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