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