1use crate::llm::actions::{
4 client_trait::{Client, ClientActionResult},
5 protocol_trait::Protocol,
6 ActionDefinition, Parameter, ParameterDefinition,
7};
8use crate::protocol::EventType;
9use crate::state::app_state::AppState;
10use anyhow::{Context, Result};
11use serde_json::json;
12use std::sync::LazyLock;
13
14pub const MIN_ETHERNET_FRAME_BYTES: usize = 14;
19
20pub const MAX_ETHERNET_FRAME_BYTES: usize = 65535;
23
24pub const ETHERTYPE_NAMES: &[(&str, u16)] = &[
31 ("ipv4", 0x0800),
32 ("arp", 0x0806),
33 ("vlan", 0x8100),
34 ("ipv6", 0x86dd),
35];
36
37pub fn format_mac(bytes: &[u8]) -> String {
39 bytes
40 .iter()
41 .map(|b| format!("{b:02x}"))
42 .collect::<Vec<_>>()
43 .join(":")
44}
45
46fn parse_mac(value: &str, field: &str) -> Result<[u8; 6]> {
48 let cleaned: String = value
49 .chars()
50 .filter(|c| !matches!(c, ':' | '-' | '.' | ' ' | '\t'))
51 .collect();
52 let bytes = hex::decode(&cleaned).map_err(|e| {
53 anyhow::anyhow!(
54 "'{field}' is not a MAC address ({value:?}): {e}. Write six bytes, e.g. \
55 \"00:11:22:33:44:55\"."
56 )
57 })?;
58 if bytes.len() != 6 {
59 anyhow::bail!(
60 "'{field}' decodes to {} bytes; a MAC address is 6, e.g. \"00:11:22:33:44:55\" \
61 (or \"ff:ff:ff:ff:ff:ff\" to broadcast)",
62 bytes.len()
63 );
64 }
65 let mut out = [0u8; 6];
66 out.copy_from_slice(&bytes);
67 Ok(out)
68}
69
70fn parse_ethertype(value: &serde_json::Value) -> Result<u16> {
76 if let Some(n) = value.as_u64() {
77 return u16::try_from(n).map_err(|_| {
78 anyhow::anyhow!("'ethertype' {n} does not fit in the 2-byte EtherType field")
79 });
80 }
81 let Some(text) = value.as_str() else {
82 anyhow::bail!(
83 "'ethertype' must be a name ({}), a number (2054), or a hex string with a 0x \
84 prefix (\"0x0806\"); got {value}",
85 ETHERTYPE_NAMES
86 .iter()
87 .map(|(n, _)| *n)
88 .collect::<Vec<_>>()
89 .join(", ")
90 );
91 };
92 let lowered = text.trim().to_ascii_lowercase();
93 if let Some((_, v)) = ETHERTYPE_NAMES.iter().find(|(n, _)| *n == lowered) {
94 return Ok(*v);
95 }
96 if let Some(digits) = lowered.strip_prefix("0x") {
97 return u16::from_str_radix(digits, 16)
98 .map_err(|e| anyhow::anyhow!("'ethertype' {text:?} is not a 2-byte hex value: {e}"));
99 }
100 anyhow::bail!(
101 "'ethertype' {text:?} is neither a known name ({}) nor a 0x-prefixed hex value. A bare \
102 \"0806\" is refused because it is 2054 as hex and 806 as decimal and only you know \
103 which; write \"0x0806\", 2054, or \"arp\".",
104 ETHERTYPE_NAMES
105 .iter()
106 .map(|(n, _)| *n)
107 .collect::<Vec<_>>()
108 .join(", ")
109 )
110}
111
112fn parse_payload(action: &serde_json::Value) -> Result<Vec<u8>> {
120 let Some(payload) = action.get("payload").and_then(|v| v.as_str()) else {
121 return Ok(Vec::new());
122 };
123 let Some(encoding) = action.get("payload_encoding").and_then(|v| v.as_str()) else {
124 anyhow::bail!(
125 "'payload' was given without 'payload_encoding'. Say which it is: \"hex\" for \
126 binary (ARP, IP, anything with a header), \"utf8\" to put the characters of \
127 'payload' on the wire as they are. There is no default and nothing is guessed - \
128 \"deadbeef\" is four bytes as hex and eight characters as text."
129 );
130 };
131 match encoding {
132 "utf8" => Ok(payload.as_bytes().to_vec()),
133 "hex" => {
134 let cleaned: String = payload
135 .chars()
136 .filter(|c| !matches!(c, ' ' | ':' | '-' | '.' | '\n' | '\r' | '\t'))
137 .collect();
138 if cleaned.len() % 2 != 0 {
139 anyhow::bail!(
140 "Invalid hex in 'payload': expected an even number of hex digits, got {}. \
141 Each byte is two hex digits.",
142 cleaned.len()
143 );
144 }
145 hex::decode(&cleaned)
146 .map_err(|e| anyhow::anyhow!("Invalid hex in 'payload' ({payload:?}): {e}"))
147 }
148 other => anyhow::bail!(
149 "Invalid 'payload_encoding' value {other:?}. Valid values are \"hex\" (decode \
150 'payload' as hex-encoded bytes) and \"utf8\" (send its characters as they are)."
151 ),
152 }
153}
154
155pub fn build_frame(action: &serde_json::Value) -> Result<Vec<u8>> {
171 let frame_hex = action.get("frame_hex").and_then(|v| v.as_str());
172 let structured = ["dst_mac", "src_mac", "ethertype"]
173 .iter()
174 .any(|k| action.get(k).is_some());
175
176 let frame = match (frame_hex, structured) {
177 (Some(_), true) => anyhow::bail!(
178 "Both 'frame_hex' and the structured fields (dst_mac / src_mac / ethertype) were \
179 supplied. Send exactly one: the structured form for a frame you are building, or \
180 'frame_hex' for a frame you already have in full. They are not combined and \
181 neither takes precedence."
182 ),
183 (None, false) => anyhow::bail!(
184 "Missing frame. Give 'dst_mac', 'src_mac' and 'ethertype' (plus an optional \
185 'payload' with its 'payload_encoding'), or the whole frame as 'frame_hex'."
186 ),
187 (Some(h), false) => decode_frame_hex(h)?,
188 (None, true) => {
189 let get = |k: &str| -> Result<&serde_json::Value> {
190 action.get(k).ok_or_else(|| {
191 anyhow::anyhow!(
192 "Missing '{k}'. A structured frame needs all of 'dst_mac', 'src_mac' \
193 and 'ethertype'; 'payload' and its 'payload_encoding' are optional."
194 )
195 })
196 };
197 let dst = parse_mac(get("dst_mac")?.as_str().unwrap_or_default(), "dst_mac")?;
198 let src = parse_mac(get("src_mac")?.as_str().unwrap_or_default(), "src_mac")?;
199 let ethertype = parse_ethertype(get("ethertype")?)?;
200
201 let payload = parse_payload(action)?;
202 let mut frame = Vec::with_capacity(MIN_ETHERNET_FRAME_BYTES + payload.len());
203 frame.extend_from_slice(&dst);
204 frame.extend_from_slice(&src);
205 frame.extend_from_slice(ðertype.to_be_bytes());
206 frame.extend_from_slice(&payload);
207 frame
208 }
209 };
210
211 if frame.len() < MIN_ETHERNET_FRAME_BYTES {
215 anyhow::bail!(
216 "the frame is {} bytes; an Ethernet frame needs at least {} (6-byte destination \
217 MAC, 6-byte source MAC, 2-byte EtherType)",
218 frame.len(),
219 MIN_ETHERNET_FRAME_BYTES
220 );
221 }
222 if frame.len() > MAX_ETHERNET_FRAME_BYTES {
223 anyhow::bail!(
224 "the frame is {} bytes; the maximum injectable frame is {}",
225 frame.len(),
226 MAX_ETHERNET_FRAME_BYTES
227 );
228 }
229
230 Ok(frame)
231}
232
233fn decode_frame_hex(frame_hex: &str) -> Result<Vec<u8>> {
237 let cleaned: String = frame_hex
238 .chars()
239 .filter(|c| !matches!(c, ' ' | ':' | '-' | '.' | '\n' | '\r' | '\t'))
240 .collect();
241 hex::decode(&cleaned).context("Invalid hex frame data")
242}
243
244fn frame_parameters(what: &str) -> Vec<Parameter> {
247 vec![
248 Parameter {
249 name: "dst_mac".to_string(),
250 type_hint: "string".to_string(),
251 description: format!(
252 "Destination MAC of {what}, as \"aa:bb:cc:dd:ee:ff\". Pass it straight back \
253 to inject_frame. Null only when the captured bytes are shorter than an \
254 Ethernet header."
255 ),
256 required: true,
257 },
258 Parameter {
259 name: "src_mac".to_string(),
260 type_hint: "string".to_string(),
261 description: format!("Source MAC of {what}, in the same spelling as dst_mac"),
262 required: true,
263 },
264 Parameter {
265 name: "ethertype".to_string(),
266 type_hint: "string".to_string(),
267 description: format!(
268 "EtherType of {what} as a 0x-prefixed hex string, e.g. \"0x0806\" (ARP) or \
269 \"0x0800\" (IPv4). inject_frame accepts this spelling unchanged."
270 ),
271 required: true,
272 },
273 Parameter {
274 name: "payload".to_string(),
275 type_hint: "string".to_string(),
276 description: format!(
277 "Everything in {what} after the 14-byte Ethernet header, read according to \
278 'payload_encoding'. A prefix when `truncated` is true."
279 ),
280 required: true,
281 },
282 Parameter {
283 name: "payload_encoding".to_string(),
284 type_hint: "string".to_string(),
285 description: "How to read 'payload': \"utf8\" means it is the payload bytes as \
286 literal text, \"hex\" means it is those bytes hex-encoded. Pass \
287 'payload' and 'payload_encoding' unchanged to inject_frame to put \
288 the same bytes back on the wire."
289 .to_string(),
290 required: true,
291 },
292 Parameter {
293 name: "frame_hex".to_string(),
294 type_hint: "string".to_string(),
295 description: format!(
296 "The whole of the first {} bytes of {} as hex (dst MAC, src MAC, EtherType, \
297 payload), for replaying it verbatim through inject_frame's 'frame_hex' \
298 escape hatch. Longer frames are cut there - see `truncated`.",
299 crate::client::datalink::MAX_HEX_BYTES_TO_MODEL,
300 what
301 ),
302 required: true,
303 },
304 Parameter {
305 name: "frame_length".to_string(),
306 type_hint: "number".to_string(),
307 description: "Full length of the frame in bytes, before any truncation".to_string(),
308 required: true,
309 },
310 Parameter {
311 name: "captured_length".to_string(),
312 type_hint: "number".to_string(),
313 description: "Number of bytes actually encoded in frame_hex".to_string(),
314 required: true,
315 },
316 Parameter {
317 name: "truncated".to_string(),
318 type_hint: "boolean".to_string(),
319 description: "True when frame_hex is a prefix of the frame rather than all of it"
320 .to_string(),
321 required: true,
322 },
323 ]
324}
325
326pub static DATALINK_CLIENT_FRAME_CAPTURED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
328 EventType::new(
329 "datalink_frame_captured",
330 "Raw Ethernet frame captured on interface",
331 json!({
332 "type": "inject_frame",
333 "dst_mac": "ff:ff:ff:ff:ff:ff",
334 "src_mac": "00:11:22:33:44:55",
335 "ethertype": "0x88b5",
336 "payload": "netget",
337 "payload_encoding": "utf8"
338 }),
339 )
340 .with_parameters(frame_parameters("the captured frame"))
341 .with_actions(vec![inject_frame_action(true), wait_for_more_action()])
342});
343
344pub static DATALINK_CLIENT_CONNECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
349 EventType::new(
350 "datalink_connected",
351 "Capture handle open on the interface; ready to inject and capture",
352 json!({
353 "type": "inject_frame",
354 "dst_mac": "ff:ff:ff:ff:ff:ff",
355 "src_mac": "00:11:22:33:44:55",
356 "ethertype": "0x88b5",
357 "payload": "netget",
358 "payload_encoding": "utf8"
359 }),
360 )
361 .with_parameters(vec![
362 Parameter {
363 name: "interface".to_string(),
364 type_hint: "string".to_string(),
365 description: "The interface the capture was opened on".to_string(),
366 required: true,
367 },
368 Parameter {
369 name: "promiscuous".to_string(),
370 type_hint: "boolean".to_string(),
371 description: "Whether the interface was opened in promiscuous mode".to_string(),
372 required: true,
373 },
374 ])
375 .with_actions(vec![inject_frame_action(false), wait_for_more_action()])
376});
377
378pub static DATALINK_CLIENT_FRAME_INJECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
380 EventType::new(
381 "datalink_frame_injected",
382 "A raw frame was written to the interface",
383 json!({"type": "wait_for_more"}),
384 )
385 .with_parameters(frame_parameters("the frame that was written"))
386 .with_actions(vec![inject_frame_action(false), wait_for_more_action()])
387});
388
389fn inject_frame_action(in_response: bool) -> ActionDefinition {
396 ActionDefinition {
397 name: "inject_frame".to_string(),
398 description: format!(
399 "{} Build it from fields: 'dst_mac' and 'src_mac' as \"aa:bb:cc:dd:ee:ff\", \
400 'ethertype' as a name ({}), a number (2054) or a 0x string (\"0x0806\"), and an \
401 optional 'payload' whose 'payload_encoding' says whether it is \"hex\" (binary - \
402 ARP, IP, anything with a header) or \"utf8\" (its characters, as they are). \
403 'payload_encoding' has no default and nothing is sniffed: \"deadbeef\" is four \
404 bytes as hex and eight characters as text. For an ARP request set 'ethertype' to \
405 \"arp\" and put the 28-byte RFC 826 body in 'payload' as hex. The 14-byte \
406 Ethernet header is assembled for you, so do not repeat it in 'payload'. Do NOT \
407 append the FCS - the interface computes it. The whole frame must be at least \
408 {MIN_ETHERNET_FRAME_BYTES} and at most {MAX_ETHERNET_FRAME_BYTES} bytes. \
409 Alternatively, for a frame you already have in full - one a \
410 datalink_frame_captured event handed you, say - pass it as 'frame_hex' instead; \
411 supplying both spellings is refused rather than guessed.",
412 if in_response {
413 "Inject a raw Ethernet frame in response to a captured frame."
414 } else {
415 "Inject a raw Ethernet frame onto the network."
416 },
417 ETHERTYPE_NAMES
418 .iter()
419 .map(|(n, _)| *n)
420 .collect::<Vec<_>>()
421 .join(", ")
422 ),
423 parameters: vec![
424 Parameter {
425 name: "dst_mac".to_string(),
426 type_hint: "string".to_string(),
427 description: "Destination MAC address, e.g. \"00:11:22:33:44:55\", or \
428 \"ff:ff:ff:ff:ff:ff\" to broadcast. Required unless \
429 'frame_hex' is used."
430 .to_string(),
431 required: false,
432 },
433 Parameter {
434 name: "src_mac".to_string(),
435 type_hint: "string".to_string(),
436 description: "Source MAC address, same spelling as 'dst_mac'. Required unless \
437 'frame_hex' is used."
438 .to_string(),
439 required: false,
440 },
441 Parameter {
442 name: "ethertype".to_string(),
443 type_hint: "string".to_string(),
444 description: format!(
445 "What the payload is: a name ({}), a number (2054), or a 0x-prefixed hex \
446 string (\"0x0806\"). A bare \"0806\" is refused - it is 2054 as hex \
447 and 806 as decimal. Required unless 'frame_hex' is used.",
448 ETHERTYPE_NAMES
449 .iter()
450 .map(|(n, _)| *n)
451 .collect::<Vec<_>>()
452 .join(", ")
453 ),
454 required: false,
455 },
456 Parameter {
457 name: "payload".to_string(),
458 type_hint: "string".to_string(),
459 description: "Everything after the 14-byte Ethernet header, read according to \
460 'payload_encoding'. Omit it for a header-only frame."
461 .to_string(),
462 required: false,
463 },
464 Parameter {
465 name: "payload_encoding".to_string(),
466 type_hint: "string".to_string(),
467 description: "How to turn 'payload' into bytes. \"hex\" decodes it as \
468 hex-encoded bytes, two hex digits per byte - use it for ARP, IP \
469 and anything else binary. \"utf8\" puts its characters on the \
470 wire unchanged. There is NO default and no auto-detection: \
471 required whenever 'payload' is present."
472 .to_string(),
473 required: false,
474 }
475 .with_choices(["hex", "utf8"]),
476 Parameter {
477 name: "frame_hex".to_string(),
478 type_hint: "string".to_string(),
479 description: format!(
480 "Escape hatch: a complete Ethernet frame as hex, header included, for \
481 replaying one a datalink_frame_captured event gave you. Prefer the \
482 structured fields for a frame you are composing - a model cannot \
483 proofread hex. At least {MIN_ETHERNET_FRAME_BYTES} and at most \
484 {MAX_ETHERNET_FRAME_BYTES} bytes, i.e. {} to {} hex characters. Cannot \
485 be combined with 'dst_mac' / 'src_mac' / 'ethertype'.",
486 MIN_ETHERNET_FRAME_BYTES * 2,
487 MAX_ETHERNET_FRAME_BYTES * 2
488 ),
489 required: false,
490 },
491 ],
492 example: json!({
493 "type": "inject_frame",
494 "dst_mac": "ff:ff:ff:ff:ff:ff",
495 "src_mac": "00:11:22:33:44:55",
496 "ethertype": "0x88b5",
497 "payload": "netget",
498 "payload_encoding": "utf8"
499 }),
500 log_template: None,
501 }
502}
503
504fn wait_for_more_action() -> ActionDefinition {
505 ActionDefinition {
506 name: "wait_for_more".to_string(),
507 description: "Wait for more frames before responding".to_string(),
508 parameters: vec![],
509 example: json!({
510 "type": "wait_for_more"
511 }),
512 log_template: None,
513 }
514}
515
516pub struct DataLinkClientProtocol;
518
519impl DataLinkClientProtocol {
520 pub fn new() -> Self {
521 Self
522 }
523}
524
525impl Protocol for DataLinkClientProtocol {
527 fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
528 vec![
529 ParameterDefinition {
530 name: "interface".to_string(),
531 type_hint: "string".to_string(),
532 description:
533 "Network interface name for frame injection (e.g., 'eth0', 'en0', 'wlan0')"
534 .to_string(),
535 required: true,
536 example: json!("eth0"),
537 default: None,
538 },
539 ParameterDefinition {
540 name: "promiscuous".to_string(),
541 type_hint: "boolean".to_string(),
542 description:
543 "Enable promiscuous mode to capture frames (requires root/CAP_NET_RAW)"
544 .to_string(),
545 required: false,
546 example: json!(false),
547 default: None,
548 },
549 ]
550 }
551 fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
552 vec![
553 inject_frame_action(false),
554 ActionDefinition {
555 name: "disconnect".to_string(),
556 description: "Close the DataLink client and release the interface".to_string(),
557 parameters: vec![],
558 example: json!({
559 "type": "disconnect"
560 }),
561 log_template: None,
562 },
563 ]
564 }
565 fn get_sync_actions(&self) -> Vec<ActionDefinition> {
566 vec![inject_frame_action(true), wait_for_more_action()]
567 }
568 fn protocol_name(&self) -> &'static str {
569 "DataLink"
570 }
571 fn get_event_types(&self) -> Vec<EventType> {
572 vec![
576 DATALINK_CLIENT_CONNECTED_EVENT.clone(),
577 DATALINK_CLIENT_FRAME_INJECTED_EVENT.clone(),
578 DATALINK_CLIENT_FRAME_CAPTURED_EVENT.clone(),
579 ]
580 }
581 fn stack_name(&self) -> &'static str {
582 "ETH"
583 }
584 fn keywords(&self) -> Vec<&'static str> {
585 vec![
586 "datalink",
587 "data link",
588 "layer 2",
589 "layer2",
590 "l2",
591 "ethernet",
592 "frame",
593 "inject",
594 "pcap",
595 ]
596 }
597 fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
598 use crate::protocol::metadata::{
599 DevelopmentState, PrivilegeRequirement, ProtocolMetadataV2,
600 };
601
602 ProtocolMetadataV2::builder()
603 .state(DevelopmentState::Experimental)
604 .privilege_requirement(PrivilegeRequirement::PacketCapture)
609 .implementation("libpcap (pcap crate) for Layer 2 frame injection and capture")
610 .llm_control("Full control over Ethernet frames (inject/capture)")
611 .e2e_testing(
612 "tests/client/datalink: `action_test.rs` pins the action/event surface with no \
613 privilege at all (every declared example is accepted by execute_action, and a \
614 runt, an over-long frame and bad hex are refused by name). \
615 `command_channel_test.rs` covers the dashboard's [ send ] path, including a \
616 real acknowledged `sendpacket` in its privileged half. The subprocess e2e \
617 tests open a real capture on loopback and inject real frames; they fail with \
618 an explicit message on a host without capture access rather than skipping.",
619 )
620 .notes(
621 "Needs layer-2 capture access (/dev/bpf* on macOS/BSD, root or CAP_NET_RAW on \
622 Linux) for both injection and promiscuous mode. connect() awaits the pcap \
623 handle and returns Err when it cannot be opened, so a host without that \
624 access lands in ClientStatus::Error rather than reporting Connected having \
625 opened nothing. Frame injection has been observed end to end on macOS \
626 (2026-09-08) but only through tests that need that access. On an LLM failure \
627 nothing is injected - the outcome is in the log as decision=model_inject / \
628 model_silent / model_reject / fail_closed_llm_error. The inject -> \
629 datalink_frame_injected -> inject chain is capped at 4 hops; captured frames \
630 arriving while a model turn is running are dropped and counted, not queued.",
631 )
632 .build()
633 }
634 fn description(&self) -> &'static str {
635 "DataLink client for raw Ethernet frame injection"
636 }
637 fn example_prompt(&self) -> &'static str {
638 "Inject ARP request on eth0"
639 }
640 fn group_name(&self) -> &'static str {
641 "Core"
642 }
643
644 fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
645 use crate::llm::actions::StartupExamples;
646 use serde_json::json;
647
648 StartupExamples::new(
649 json!({
651 "type": "open_client",
652 "remote_addr": "eth0",
653 "base_stack": "datalink",
654 "instruction": "Inject an ARP request and capture responses",
655 "startup_params": {
656 "interface": "eth0",
657 "promiscuous": true
658 }
659 }),
660 json!({
662 "type": "open_client",
663 "remote_addr": "eth0",
664 "base_stack": "datalink",
665 "startup_params": {
666 "interface": "eth0",
667 "promiscuous": true
668 },
669 "event_handlers": [{
670 "event_pattern": "datalink_frame_captured",
671 "handler": {
672 "type": "script",
673 "language": "python",
674 "code": "<datalink_handler>"
675 }
676 }]
677 }),
678 json!({
680 "type": "open_client",
681 "remote_addr": "eth0",
682 "base_stack": "datalink",
683 "startup_params": {
684 "interface": "eth0",
685 "promiscuous": false
686 },
687 "event_handlers": [{
688 "event_pattern": "datalink_frame_injected",
689 "handler": {
690 "type": "static",
691 "actions": [{
692 "type": "wait_for_more"
693 }]
694 }
695 }]
696 }),
697 )
698 }
699}
700
701impl Client for DataLinkClientProtocol {
703 fn connect(
704 &self,
705 ctx: crate::protocol::ConnectContext,
706 ) -> std::pin::Pin<
707 Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
708 > {
709 Box::pin(async move {
710 use crate::client::datalink::DataLinkClient;
711 DataLinkClient::connect_with_llm_actions(
712 ctx.remote_addr,
713 ctx.llm_client,
714 ctx.state,
715 ctx.status_tx,
716 ctx.client_id,
717 ctx.startup_params,
718 )
719 .await
720 })
721 }
722 fn execute_action(&self, action: serde_json::Value) -> Result<ClientActionResult> {
723 let action_type = action
724 .get("type")
725 .and_then(|v| v.as_str())
726 .context("Missing 'type' field in action")?;
727
728 match action_type {
729 "inject_frame" => Ok(ClientActionResult::SendData(build_frame(&action)?)),
730 "disconnect" => Ok(ClientActionResult::Disconnect),
731 "wait_for_more" => Ok(ClientActionResult::WaitForMore),
732 _ => Err(anyhow::anyhow!(
733 "Unknown DataLink client action: {}",
734 action_type
735 )),
736 }
737 }
738}