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