Skip to main content

netget/client/vnc/
mod.rs

1//! VNC (Remote Framebuffer) client implementation
2pub mod actions;
3
4pub use actions::VncClientProtocol;
5
6use anyhow::{anyhow, bail, Context, Result};
7use std::net::SocketAddr;
8use std::sync::Arc;
9use tokio::io::{AsyncReadExt, AsyncWriteExt};
10use tokio::net::TcpStream;
11use tokio::sync::{mpsc, Mutex};
12use tracing::{debug, error, info, trace, warn};
13
14use crate::client::llm_budget::call_llm_for_client;
15use crate::client::vnc::actions::{
16    VNC_CLIENT_CONNECTED_EVENT, VNC_CLIENT_FRAMEBUFFER_UPDATE_EVENT,
17    VNC_CLIENT_SERVER_CUT_TEXT_EVENT,
18};
19use crate::llm::actions::client_trait::ClientActionResult;
20use crate::llm::ollama_client::OllamaClient;
21use crate::llm::ClientLlmResult;
22use crate::protocol::{Event, StartupParams};
23use crate::state::app_state::AppState;
24use crate::state::{ClientId, ClientStatus};
25use serde_json::Value as JsonValue;
26
27/// Maximum length of server names, failure explanations and clipboard text.
28pub const MAX_TEXT_BYTES: usize = 1024 * 1024;
29
30/// Largest raw rectangle accepted (enough for an 8K 32-bit display).
31pub const MAX_RAW_RECTANGLE_BYTES: u64 = 256 * 1024 * 1024;
32
33/// Read a server-controlled string length only after bounding its allocation.
34pub async fn read_server_text<R: AsyncReadExt + Unpin>(
35    reader: &mut R,
36    length: u32,
37) -> Result<Vec<u8>> {
38    if u64::from(length) > MAX_TEXT_BYTES as u64 {
39        bail!("VNC text length {length} exceeds the {MAX_TEXT_BYTES}-byte cap");
40    }
41    let mut text = vec![0; length as usize];
42    reader.read_exact(&mut text).await?;
43    Ok(text)
44}
45
46/// Consume raw pixels with bounded scratch space; the client reports update metadata
47/// rather than pixels, so retaining a full rectangle has no purpose.
48pub async fn discard_raw_rectangle<R: AsyncReadExt + Unpin>(
49    reader: &mut R,
50    width: u16,
51    height: u16,
52) -> Result<()> {
53    let length = u64::from(width) * u64::from(height) * 4;
54    if length > MAX_RAW_RECTANGLE_BYTES {
55        bail!("VNC raw rectangle is {length} bytes, over the {MAX_RAW_RECTANGLE_BYTES}-byte cap");
56    }
57    let consumed = tokio::io::copy(&mut reader.take(length), &mut tokio::io::sink()).await?;
58    if consumed != length {
59        bail!("truncated VNC raw rectangle: expected {length} bytes, received {consumed}");
60    }
61    Ok(())
62}
63
64/// Resolve the `key` field of `send_key_event` to an X11 keysym.
65///
66/// Accepts a number (a keysym directly) or a **name** — a single character such as `"a"`, or a
67/// named key such as `"Enter"`, `"Escape"`, `"Tab"`, `"F1"`. The action originally took a bare
68/// number described as an "X11 keysym value", which asks a model to recall an encoding table;
69/// CLAUDE.md's action-design rule is explicit that models cannot reliably produce encoded
70/// values, and a model answering `"a"` silently sent keysym 0 because `as_u64()` on a string is
71/// `None` and the code fell through to `unwrap_or(0)`. A wrong keysym is invisible: the server
72/// receives a well-formed KeyEvent for a key nobody pressed.
73///
74/// Returns `None` for an unrecognised name so the caller can refuse rather than send keysym 0.
75pub fn resolve_keysym(value: &serde_json::Value) -> Option<u32> {
76    if let Some(n) = value.as_u64() {
77        return u32::try_from(n).ok();
78    }
79    let name = value.as_str()?;
80
81    // ASCII printables are their own keysym (X11 keysymdef).
82    let mut chars = name.chars();
83    if let (Some(c), None) = (chars.next(), chars.next()) {
84        if c.is_ascii_graphic() || c == ' ' {
85            return Some(c as u32);
86        }
87    }
88
89    Some(match name.to_ascii_lowercase().as_str() {
90        "enter" | "return" => 0xff0d,
91        "backspace" => 0xff08,
92        "tab" => 0xff09,
93        "escape" | "esc" => 0xff1b,
94        "space" => 0x0020,
95        "delete" | "del" => 0xffff,
96        "home" => 0xff50,
97        "left" => 0xff51,
98        "up" => 0xff52,
99        "right" => 0xff53,
100        "down" => 0xff54,
101        "pageup" => 0xff55,
102        "pagedown" => 0xff56,
103        "end" => 0xff57,
104        "insert" => 0xff63,
105        "shift" => 0xffe1,
106        "control" | "ctrl" => 0xffe3,
107        "alt" => 0xffe9,
108        "meta" | "super" | "cmd" => 0xffeb,
109        other => {
110            // F1..F35
111            let n: u32 = other.strip_prefix('f')?.parse().ok()?;
112            if (1..=35).contains(&n) {
113                0xffbe + (n - 1)
114            } else {
115                return None;
116            }
117        }
118    })
119}
120
121/// Connection state for LLM processing
122#[derive(Debug, Clone, PartialEq)]
123enum ConnectionState {
124    Idle,
125    Processing,
126    #[allow(dead_code)]
127    Accumulating,
128}
129
130/// Per-client data for LLM handling
131struct ClientData {
132    state: ConnectionState,
133    memory: String,
134    fb_width: u16,
135    fb_height: u16,
136}
137
138/// VNC client that connects to a VNC server
139/// RFB security type 2: DES-ECB encrypt both challenge blocks with the first
140/// eight password bytes, zero padded and bit reversed for VNC's DES key order.
141pub fn vnc_auth_response(password: &[u8], mut challenge: [u8; 16]) -> [u8; 16] {
142    use des::cipher::{Block, BlockEncrypt, KeyInit};
143    let mut key = [0u8; 8];
144    for (target, source) in key.iter_mut().zip(password.iter()) {
145        *target = source.reverse_bits();
146    }
147    let cipher = des::Des::new_from_slice(&key).expect("DES uses an eight-byte key");
148    for block in challenge.chunks_exact_mut(8) {
149        let mut encrypted = Block::<des::Des>::default();
150        encrypted.copy_from_slice(block);
151        cipher.encrypt_block(&mut encrypted);
152        block.copy_from_slice(&encrypted);
153    }
154    challenge
155}
156
157pub struct VncClient;
158
159impl VncClient {
160    /// Connect to a VNC server with integrated LLM actions
161    pub async fn connect_with_llm_actions(
162        remote_addr: String,
163        llm_client: OllamaClient,
164        app_state: Arc<AppState>,
165        status_tx: mpsc::UnboundedSender<String>,
166        client_id: ClientId,
167        startup_params: Option<StartupParams>,
168    ) -> Result<SocketAddr> {
169        // Resolve and connect
170        let mut stream = TcpStream::connect(&remote_addr)
171            .await
172            .context(format!("Failed to connect to {}", remote_addr))?;
173
174        let local_addr = stream.local_addr()?;
175        let remote_sock_addr = stream.peer_addr()?;
176
177        info!(
178            "VNC client {} connecting to {} (local: {})",
179            client_id, remote_sock_addr, local_addr
180        );
181
182        // Extract password if provided
183        let password = startup_params
184            .as_ref()
185            .map(|p| p.get_optional_string("password"))
186            .transpose()?
187            .flatten();
188
189        // Perform VNC handshake
190        let (fb_width, fb_height, server_name) = tokio::time::timeout(
191            crate::client::response_reader::RESPONSE_DEADLINE,
192            Self::perform_handshake(&mut stream, password.as_deref()),
193        )
194        .await
195        .context("VNC handshake deadline exceeded")??;
196
197        info!(
198            "VNC client {} connected: {}x{} ({})",
199            client_id, fb_width, fb_height, server_name
200        );
201
202        // Update client state
203        app_state
204            .update_client_status(client_id, ClientStatus::Connected)
205            .await;
206        let _ = status_tx.send(format!("[CLIENT] VNC client {} connected", client_id));
207        let _ = status_tx.send("__UPDATE_UI__".to_string());
208
209        let protocol = Arc::new(VncClientProtocol::new());
210
211        // Split the stream now so the write half is a shared `Arc<Mutex<_>>`: the read loop, the
212        // connected-event actions, and the injected-command task all write through it.
213        let (read_half, write_half) = tokio::io::split(stream);
214        let mut read_half = crate::client::response_reader::FrameReader::new(read_half);
215        let write_half_arc = Arc::new(Mutex::new(write_half));
216
217        // Command channel for injected actions (the dashboard's [ send_key_event ] etc.).
218        // Registered - and drained - BEFORE the connected-event LLM call, which a manual `*` rule
219        // can park for minutes: the operator must be able to reach the client while it waits.
220        // The read loop uses `read_exact` (not cancellation-safe), so commands are drained by a
221        // separate task rather than a `select!` arm; both share the write half.
222        let command_rx =
223            crate::client::command_support::register_command_channel(&app_state, client_id).await;
224        let cmd_protocol = protocol.clone();
225        let cmd_write = write_half_arc.clone();
226        let cmd_state = app_state.clone();
227        let cmd_tx = status_tx.clone();
228        let cmd_task = tokio::spawn(async move {
229            Self::command_loop(
230                command_rx,
231                cmd_protocol,
232                cmd_write,
233                client_id,
234                fb_width,
235                fb_height,
236                cmd_state,
237                cmd_tx,
238            )
239            .await;
240        });
241        app_state.register_client_task(client_id, cmd_task).await;
242
243        // Fire connected event
244        if let Some(instruction) = app_state.get_instruction_for_client(client_id).await {
245            let event = Event::new(
246                &VNC_CLIENT_CONNECTED_EVENT,
247                serde_json::json!({
248                    "remote_addr": remote_addr,
249                    "width": fb_width,
250                    "height": fb_height,
251                    "server_name": server_name,
252                }),
253            );
254
255            // Call LLM with connected event
256            match call_llm_for_client(
257                &llm_client,
258                &app_state,
259                client_id.to_string(),
260                &instruction,
261                "",
262                Some(&event),
263                protocol.as_ref(),
264                &status_tx,
265            )
266            .await
267            {
268                Ok(ClientLlmResult {
269                    actions,
270                    memory_updates: _,
271                }) => {
272                    // Execute initial actions through the shared write half.
273                    for action in actions {
274                        let mut guard = write_half_arc.lock().await;
275                        if let Err(e) = Self::execute_vnc_action_with_writer(
276                            &mut *guard,
277                            &protocol,
278                            action,
279                            fb_width,
280                            fb_height,
281                        )
282                        .await
283                        {
284                            error!("Failed to execute VNC action: {}", e);
285                        }
286                    }
287                }
288                Err(e) => {
289                    error!("LLM error for VNC client {}: {}", client_id, e);
290                }
291            }
292        }
293
294        // Initialize client data
295        let client_data = Arc::new(Mutex::new(ClientData {
296            state: ConnectionState::Idle,
297            memory: String::new(),
298            fb_width,
299            fb_height,
300        }));
301
302        // Spawn read loop for server messages
303        // Registered with AppState so stop_client can abort this task —
304        // dropping a JoinHandle only detaches it in Tokio.
305        let task_registrar = app_state.clone();
306        let task_handle = tokio::spawn(async move {
307            loop {
308                read_half.end_frame();
309                // Read message type
310                let mut msg_type_buf = [0u8; 1];
311                match read_half.read_exact(&mut msg_type_buf).await {
312                    Ok(_) => {
313                        read_half.start_frame(crate::client::response_reader::RESPONSE_DEADLINE);
314                        let msg_type = msg_type_buf[0];
315                        trace!(
316                            "VNC client {} received message type: {}",
317                            client_id,
318                            msg_type
319                        );
320
321                        match msg_type {
322                            0 => {
323                                // FramebufferUpdate
324                                if let Err(e) = Self::handle_framebuffer_update(
325                                    &mut read_half,
326                                    &llm_client,
327                                    &app_state,
328                                    &status_tx,
329                                    client_id,
330                                    &client_data,
331                                    &protocol,
332                                    &write_half_arc,
333                                )
334                                .await
335                                {
336                                    error!("Failed to handle framebuffer update: {}", e);
337                                    break;
338                                }
339                            }
340                            1 => {
341                                // SetColourMapEntries
342                                if let Err(e) =
343                                    Self::handle_set_colour_map_entries(&mut read_half).await
344                                {
345                                    error!("Failed to handle SetColourMapEntries: {}", e);
346                                    break;
347                                }
348                            }
349                            2 => {
350                                // Bell
351                                debug!("VNC client {}: Bell received", client_id);
352                            }
353                            3 => {
354                                // ServerCutText
355                                if let Err(e) = Self::handle_server_cut_text(
356                                    &mut read_half,
357                                    &llm_client,
358                                    &app_state,
359                                    &status_tx,
360                                    client_id,
361                                    &client_data,
362                                    &protocol,
363                                    &write_half_arc,
364                                )
365                                .await
366                                {
367                                    error!("Failed to handle server cut text: {}", e);
368                                    break;
369                                }
370                            }
371                            _ => {
372                                warn!(
373                                    "VNC client {}: Unknown message type: {}",
374                                    client_id, msg_type
375                                );
376                                break;
377                            }
378                        }
379                    }
380                    Err(e) => {
381                        info!("VNC client {} disconnected: {}", client_id, e);
382                        break;
383                    }
384                }
385            }
386            // A framing failure is terminal too: its unconsumed payload cannot be
387            // interpreted as a new message, and its command handle must disappear.
388            app_state
389                .update_client_status(client_id, ClientStatus::Disconnected)
390                .await;
391            app_state.remove_client_handle(client_id).await;
392            let _ = status_tx.send(format!("[CLIENT] VNC client {} disconnected", client_id));
393            let _ = status_tx.send("__UPDATE_UI__".to_string());
394        });
395        task_registrar
396            .register_client_task(client_id, task_handle)
397            .await;
398
399        Ok(local_addr)
400    }
401
402    /// Perform VNC handshake (ProtocolVersion, Security, ClientInit, ServerInit)
403    async fn perform_handshake(
404        stream: &mut TcpStream,
405        password: Option<&str>,
406    ) -> Result<(u16, u16, String)> {
407        // 1. ProtocolVersion handshake
408        let mut version_buf = [0u8; 12];
409        stream.read_exact(&mut version_buf).await?;
410        let version_str = std::str::from_utf8(&version_buf)?;
411
412        if !version_str.starts_with("RFB ") {
413            bail!("Invalid VNC protocol version: {}", version_str);
414        }
415
416        debug!("VNC server version: {}", version_str.trim());
417
418        // Send version (use 003.008 for modern VNC)
419        stream.write_all(b"RFB 003.008\n").await?;
420
421        // 2. Security handshake
422        let mut num_security_types = [0u8; 1];
423        stream.read_exact(&mut num_security_types).await?;
424
425        if num_security_types[0] == 0 {
426            // Connection failed
427            let mut reason_len = [0u8; 4];
428            stream.read_exact(&mut reason_len).await?;
429            let len = u32::from_be_bytes(reason_len);
430            let reason = read_server_text(stream, len).await?;
431            bail!(
432                "VNC connection failed: {}",
433                String::from_utf8_lossy(&reason)
434            );
435        }
436
437        let mut security_types = vec![0u8; num_security_types[0] as usize];
438        stream.read_exact(&mut security_types).await?;
439
440        debug!("VNC security types: {:?}", security_types);
441
442        // Choose security type (prefer None=1, then VNC=2)
443        let chosen_security = if password.is_some() && security_types.contains(&2) {
444            2
445        } else if security_types.contains(&1) {
446            1 // None
447        } else if security_types.contains(&2) {
448            if password.is_none() {
449                bail!("VNC server requires password authentication but no password provided");
450            }
451            2 // VNC authentication
452        } else {
453            bail!(
454                "No supported security type (server offers: {:?})",
455                security_types
456            );
457        };
458
459        stream.write_all(&[chosen_security]).await?;
460
461        // Handle VNC authentication if needed
462        if chosen_security == 2 {
463            let password = password.ok_or_else(|| anyhow!("Password required but not provided"))?;
464            Self::perform_vnc_auth(stream, password).await?;
465        }
466
467        // Read SecurityResult
468        let mut security_result = [0u8; 4];
469        stream.read_exact(&mut security_result).await?;
470        let result = u32::from_be_bytes(security_result);
471
472        if result != 0 {
473            // Authentication failed
474            let mut reason_len = [0u8; 4];
475            stream.read_exact(&mut reason_len).await?;
476            let len = u32::from_be_bytes(reason_len);
477            let reason = read_server_text(stream, len).await?;
478            bail!(
479                "VNC authentication failed: {}",
480                String::from_utf8_lossy(&reason)
481            );
482        }
483
484        // 3. ClientInit (shared-flag = 1 for shared access)
485        stream.write_all(&[1]).await?;
486
487        // 4. ServerInit
488        let mut server_init = [0u8; 24];
489        stream.read_exact(&mut server_init).await?;
490
491        let fb_width = u16::from_be_bytes([server_init[0], server_init[1]]);
492        let fb_height = u16::from_be_bytes([server_init[2], server_init[3]]);
493
494        // Skip pixel format (16 bytes)
495        let name_length = u32::from_be_bytes([
496            server_init[20],
497            server_init[21],
498            server_init[22],
499            server_init[23],
500        ]);
501
502        let name_bytes = read_server_text(stream, name_length).await?;
503        let server_name = String::from_utf8_lossy(&name_bytes).to_string();
504
505        // Request the 32-bit true-color format the raw rectangle reader consumes.
506        // A server's default may be 8 or 16 bits, so assuming 32 without negotiating
507        // it would consume the next message as pixel data and lose framing.
508        stream
509            .write_all(&[
510                0, 0, 0, 0, // SetPixelFormat and padding
511                32, 24, 0, 1, // bits per pixel, depth, little endian, true color
512                0, 255, 0, 255, 0, 255, // red, green, blue maxima
513                16, 8, 0, 0, 0, 0, // shifts and padding
514            ])
515            .await?;
516
517        // Send SetEncodings (support Raw encoding only for simplicity)
518        let set_encodings = [
519            2u8, // SetEncodings message type
520            0,   // padding
521            0, 1, // number of encodings (1)
522            0, 0, 0, 0, // Raw encoding (0)
523        ];
524        stream.write_all(&set_encodings).await?;
525
526        Ok((fb_width, fb_height, server_name))
527    }
528
529    /// Perform VNC authentication (DES challenge-response)
530    async fn perform_vnc_auth(stream: &mut TcpStream, password: &str) -> Result<()> {
531        // Read 16-byte challenge
532        let mut challenge = [0u8; 16];
533        stream.read_exact(&mut challenge).await?;
534
535        stream
536            .write_all(&vnc_auth_response(password.as_bytes(), challenge))
537            .await?;
538
539        Ok(())
540    }
541
542    /// Handle SetColourMapEntries message (read and consume data)
543    async fn handle_set_colour_map_entries<R>(read_half: &mut R) -> Result<()>
544    where
545        R: AsyncReadExt + Unpin,
546    {
547        // Read padding + first-color + number-of-colors
548        let mut header = [0u8; 5];
549        read_half.read_exact(&mut header).await?;
550
551        let first_color = u16::from_be_bytes([header[1], header[2]]);
552        let num_colors = u16::from_be_bytes([header[3], header[4]]);
553
554        trace!(
555            "SetColourMapEntries: first={}, count={}",
556            first_color,
557            num_colors
558        );
559
560        // Each color is 6 bytes (RGB as u16 each)
561        let color_data_size = (num_colors as usize) * 6;
562        let mut color_data = vec![0u8; color_data_size];
563        read_half.read_exact(&mut color_data).await?;
564
565        // Color map entries are now consumed and discarded
566        // Modern VNC servers rarely use this message
567        Ok(())
568    }
569
570    /// Handle FramebufferUpdate message
571    async fn handle_framebuffer_update<R, W>(
572        read_half: &mut R,
573        llm_client: &OllamaClient,
574        app_state: &Arc<AppState>,
575        status_tx: &mpsc::UnboundedSender<String>,
576        client_id: ClientId,
577        client_data: &Arc<Mutex<ClientData>>,
578        protocol: &Arc<VncClientProtocol>,
579        write_half: &Arc<Mutex<W>>,
580    ) -> Result<()>
581    where
582        R: AsyncReadExt + Unpin,
583        W: AsyncWriteExt + Unpin,
584    {
585        // Read padding + number of rectangles
586        let mut header = [0u8; 3];
587        read_half.read_exact(&mut header).await?;
588
589        let num_rects = u16::from_be_bytes([header[1], header[2]]);
590        trace!("FramebufferUpdate: {} rectangles", num_rects);
591
592        // Read each rectangle header and consume pixel data
593        // We don't parse the actual pixels but must consume the data from the stream
594        for _ in 0..num_rects {
595            // Rectangle: x-pos (u16), y-pos (u16), width (u16), height (u16), encoding-type (i32)
596            let mut rect_header = [0u8; 12];
597            read_half.read_exact(&mut rect_header).await?;
598
599            let x = u16::from_be_bytes([rect_header[0], rect_header[1]]);
600            let y = u16::from_be_bytes([rect_header[2], rect_header[3]]);
601            let width = u16::from_be_bytes([rect_header[4], rect_header[5]]);
602            let height = u16::from_be_bytes([rect_header[6], rect_header[7]]);
603            let encoding = i32::from_be_bytes([
604                rect_header[8],
605                rect_header[9],
606                rect_header[10],
607                rect_header[11],
608            ]);
609
610            trace!(
611                "Rectangle: {}x{} at ({}, {}), encoding={}",
612                width,
613                height,
614                x,
615                y,
616                encoding
617            );
618
619            if encoding != 0 {
620                bail!("unsupported VNC rectangle encoding {encoding}; only Raw was negotiated");
621            }
622            discard_raw_rectangle(read_half, width, height).await?;
623        }
624
625        let mut client_data_lock = client_data.lock().await;
626
627        if client_data_lock.state != ConnectionState::Idle {
628            // Already processing, skip
629            return Ok(());
630        }
631
632        client_data_lock.state = ConnectionState::Processing;
633        drop(client_data_lock);
634
635        // Call LLM with update event
636        if let Some(instruction) = app_state.get_instruction_for_client(client_id).await {
637            let event = Event::new(
638                &VNC_CLIENT_FRAMEBUFFER_UPDATE_EVENT,
639                serde_json::json!({
640                    "rectangles": num_rects,
641                    "update_summary": format!("{} rectangle(s) updated", num_rects),
642                }),
643            );
644
645            match call_llm_for_client(
646                llm_client,
647                app_state,
648                client_id.to_string(),
649                &instruction,
650                &client_data.lock().await.memory,
651                Some(&event),
652                protocol.as_ref(),
653                status_tx,
654            )
655            .await
656            {
657                Ok(ClientLlmResult {
658                    actions,
659                    memory_updates,
660                }) => {
661                    // Update memory
662                    if let Some(mem) = memory_updates {
663                        client_data.lock().await.memory = mem;
664                    }
665
666                    // Execute actions
667                    let fb_width = client_data.lock().await.fb_width;
668                    let fb_height = client_data.lock().await.fb_height;
669
670                    for action in actions {
671                        let mut write_lock = write_half.lock().await;
672                        if let Err(e) = Self::execute_vnc_action_with_writer(
673                            &mut *write_lock,
674                            protocol,
675                            action,
676                            fb_width,
677                            fb_height,
678                        )
679                        .await
680                        {
681                            error!("Failed to execute VNC action: {}", e);
682                        }
683                    }
684                }
685                Err(e) => {
686                    error!("LLM error for VNC client {}: {}", client_id, e);
687                }
688            }
689        }
690
691        client_data.lock().await.state = ConnectionState::Idle;
692        Ok(())
693    }
694
695    /// Handle ServerCutText message
696    async fn handle_server_cut_text<R, W>(
697        read_half: &mut R,
698        llm_client: &OllamaClient,
699        app_state: &Arc<AppState>,
700        status_tx: &mpsc::UnboundedSender<String>,
701        client_id: ClientId,
702        client_data: &Arc<Mutex<ClientData>>,
703        protocol: &Arc<VncClientProtocol>,
704        write_half: &Arc<Mutex<W>>,
705    ) -> Result<()>
706    where
707        R: AsyncReadExt + Unpin,
708        W: AsyncWriteExt + Unpin,
709    {
710        // Read padding + text length
711        let mut header = [0u8; 7];
712        read_half.read_exact(&mut header).await?;
713
714        let text_length = u32::from_be_bytes([header[3], header[4], header[5], header[6]]);
715
716        let text_bytes = read_server_text(read_half, text_length).await?;
717
718        let text = String::from_utf8_lossy(&text_bytes).to_string();
719        debug!("VNC ServerCutText: {}", text);
720
721        // Call LLM with event
722        if let Some(instruction) = app_state.get_instruction_for_client(client_id).await {
723            let event = Event::new(
724                &VNC_CLIENT_SERVER_CUT_TEXT_EVENT,
725                serde_json::json!({
726                    "text": text,
727                }),
728            );
729
730            if let Ok(ClientLlmResult {
731                actions,
732                memory_updates,
733            }) = call_llm_for_client(
734                llm_client,
735                app_state,
736                client_id.to_string(),
737                &instruction,
738                &client_data.lock().await.memory,
739                Some(&event),
740                protocol.as_ref(),
741                status_tx,
742            )
743            .await
744            {
745                if let Some(mem) = memory_updates {
746                    client_data.lock().await.memory = mem;
747                }
748
749                let fb_width = client_data.lock().await.fb_width;
750                let fb_height = client_data.lock().await.fb_height;
751
752                for action in actions {
753                    let mut write_lock = write_half.lock().await;
754                    if let Err(e) = Self::execute_vnc_action_with_writer(
755                        &mut *write_lock,
756                        protocol,
757                        action,
758                        fb_width,
759                        fb_height,
760                    )
761                    .await
762                    {
763                        error!("Failed to execute VNC action: {}", e);
764                    }
765                }
766            }
767        }
768
769        Ok(())
770    }
771
772    /// Drain injected commands until the channel closes (client removed) or an injected
773    /// `disconnect` ends the session.
774    ///
775    /// The generic `command_support::handle_stream_client_command` cannot run this client's
776    /// vocabulary because every wire verb yields `ClientActionResult::Custom`, so the action is
777    /// encoded by [`Self::apply_injected_action`] - which reuses `send_vnc_message_with_writer`,
778    /// the exact encoder the read loop uses for LLM actions - and the outcome is logged and
779    /// replied the way the generic arm does it.
780    #[allow(clippy::too_many_arguments)]
781    async fn command_loop<W>(
782        mut command_rx: mpsc::Receiver<crate::state::client_handles::ClientCommand>,
783        protocol: Arc<VncClientProtocol>,
784        write_half: Arc<Mutex<W>>,
785        client_id: ClientId,
786        fb_width: u16,
787        fb_height: u16,
788        app_state: Arc<AppState>,
789        status_tx: mpsc::UnboundedSender<String>,
790    ) where
791        W: AsyncWriteExt + Unpin + Send + 'static,
792    {
793        use crate::llm::actions::protocol_trait::Protocol;
794        use crate::state::client_handles::ClientSendOutcome;
795        use crate::state::AccessLogOwner;
796
797        while let Some(command) = command_rx.recv().await {
798            let action = command.action.clone();
799            let outcome = Self::apply_injected_action(
800                action.clone(),
801                &protocol,
802                &write_half,
803                fb_width,
804                fb_height,
805            )
806            .await;
807
808            let outcome_json = match &outcome {
809                Ok(outcome) => serde_json::to_value(outcome).unwrap_or(serde_json::Value::Null),
810                Err(e) => serde_json::json!({"error": e.to_string()}),
811            };
812            app_state
813                .record_access_log(
814                    AccessLogOwner::Client(client_id.as_u32()),
815                    protocol.as_ref().protocol_name(),
816                    None,
817                    "injected_action",
818                    action,
819                    vec![outcome_json],
820                )
821                .await;
822
823            let disconnect = matches!(outcome, Ok(ClientSendOutcome::Disconnected));
824            if let Err(e) = &outcome {
825                error!("VNC client {} injected action failed: {}", client_id, e);
826                let _ = status_tx.send(format!(
827                    "[WARN] Client {} injected action failed: {}",
828                    client_id, e
829                ));
830            }
831            let _ = status_tx.send("__UPDATE_UI__".to_string());
832            crate::client::command_support::reply(command, outcome);
833
834            if disconnect {
835                // Half-close so the server reads EOF and the read loop runs its normal
836                // disconnect path (status → Disconnected, handle removed).
837                let _ = write_half.lock().await.shutdown().await;
838                break;
839            }
840        }
841    }
842
843    /// Encode one injected action onto the wire, reusing the read loop's encoder.
844    ///
845    /// A rejected action (bad JSON) becomes `Rejected`; a `Disconnect` becomes `Disconnected`
846    /// (the caller half-closes); a `Custom` verb is encoded into a buffer with
847    /// `send_vnc_message_with_writer` so the byte count is exact and the write happens once.
848    async fn apply_injected_action<W>(
849        action: JsonValue,
850        protocol: &Arc<VncClientProtocol>,
851        write_half: &Arc<Mutex<W>>,
852        fb_width: u16,
853        fb_height: u16,
854    ) -> Result<crate::state::client_handles::ClientSendOutcome>
855    where
856        W: AsyncWriteExt + Unpin,
857    {
858        use crate::llm::actions::client_trait::Client;
859        use crate::state::client_handles::ClientSendOutcome;
860
861        match protocol.as_ref().execute_action(action) {
862            Err(e) => Ok(ClientSendOutcome::Rejected {
863                error: e.to_string(),
864            }),
865            Ok(ClientActionResult::Custom { name, data }) => {
866                // Encode into a buffer (Vec<u8> is an AsyncWrite) with the same function the LLM
867                // path uses, so injected and model-produced messages are byte-identical.
868                let mut buf: Vec<u8> = Vec::new();
869                Self::send_vnc_message_with_writer(&mut buf, &name, &data, fb_width, fb_height)
870                    .await?;
871                let bytes_sent = buf.len();
872                {
873                    let mut guard = write_half.lock().await;
874                    guard.write_all(&buf).await?;
875                    guard.flush().await?;
876                }
877                Ok(ClientSendOutcome::Sent { bytes_sent })
878            }
879            Ok(ClientActionResult::Disconnect) => Ok(ClientSendOutcome::Disconnected),
880            // WaitForMore / NoAction / (nested) Multiple: nothing to write.
881            Ok(_) => Ok(ClientSendOutcome::Executed {
882                detail: "executed (nothing to write)".to_string(),
883            }),
884        }
885    }
886
887    /// Execute a VNC action with a writer
888    async fn execute_vnc_action_with_writer<W>(
889        writer: &mut W,
890        protocol: &Arc<VncClientProtocol>,
891        action: JsonValue,
892        fb_width: u16,
893        fb_height: u16,
894    ) -> Result<()>
895    where
896        W: AsyncWriteExt + Unpin,
897    {
898        use crate::llm::actions::client_trait::Client;
899
900        match protocol.as_ref().execute_action(action)? {
901            ClientActionResult::Custom { name, data } => {
902                Self::send_vnc_message_with_writer(writer, &name, &data, fb_width, fb_height)
903                    .await?;
904            }
905            ClientActionResult::Disconnect => {
906                return Err(anyhow!("Disconnect requested"));
907            }
908            _ => {}
909        }
910
911        Ok(())
912    }
913
914    /// Send a VNC protocol message with a writer.
915    ///
916    /// `pub` so `tests/client/vnc/coordinate_range_test.rs` can drive it against a `Vec<u8>`
917    /// and assert the exact bytes: the guards on the model's coordinates live here, and a test
918    /// of the guard functions alone would not prove they are wired in.
919    pub async fn send_vnc_message_with_writer<W>(
920        writer: &mut W,
921        action_name: &str,
922        data: &JsonValue,
923        fb_width: u16,
924        fb_height: u16,
925    ) -> Result<()>
926    where
927        W: AsyncWriteExt + Unpin,
928    {
929        match action_name {
930            "request_framebuffer_update" => {
931                let incremental = data["incremental"].as_bool().unwrap_or(true);
932                let x = rfb_u16(data, "x", 0)?;
933                let y = rfb_u16(data, "y", 0)?;
934                let width = rfb_u16(data, "width", fb_width)?;
935                let height = rfb_u16(data, "height", fb_height)?;
936
937                let msg = [
938                    3u8, // FramebufferUpdateRequest
939                    if incremental { 1 } else { 0 },
940                    (x >> 8) as u8,
941                    (x & 0xff) as u8,
942                    (y >> 8) as u8,
943                    (y & 0xff) as u8,
944                    (width >> 8) as u8,
945                    (width & 0xff) as u8,
946                    (height >> 8) as u8,
947                    (height & 0xff) as u8,
948                ];
949                writer.write_all(&msg).await?;
950            }
951            "send_pointer_event" => {
952                let x = rfb_u16(data, "x", 0)?;
953                let y = rfb_u16(data, "y", 0)?;
954                let button_mask = rfb_u8(data, "button_mask", 0)?;
955
956                let msg = [
957                    5u8, // PointerEvent
958                    button_mask,
959                    (x >> 8) as u8,
960                    (x & 0xff) as u8,
961                    (y >> 8) as u8,
962                    (y & 0xff) as u8,
963                ];
964                writer.write_all(&msg).await?;
965            }
966            "send_key_event" => {
967                let Some(key) = resolve_keysym(&data["key"]) else {
968                    anyhow::bail!(
969                        "send_key_event: unrecognised 'key' {}. Use a keysym number, a single \
970                         character like \"a\", or a name like \"Enter\"/\"Escape\"/\"F1\"",
971                        data["key"]
972                    );
973                };
974                let down = data["down"].as_bool().unwrap_or(false);
975
976                let msg = [
977                    4u8, // KeyEvent
978                    if down { 1 } else { 0 },
979                    0,
980                    0, // padding
981                    (key >> 24) as u8,
982                    (key >> 16) as u8,
983                    (key >> 8) as u8,
984                    (key & 0xff) as u8,
985                ];
986                writer.write_all(&msg).await?;
987            }
988            "send_client_cut_text" => {
989                let text = data["text"].as_str().unwrap_or("");
990                let text_bytes = text.as_bytes();
991                if text_bytes.len() > MAX_TEXT_BYTES {
992                    anyhow::bail!("VNC clipboard exceeds byte cap");
993                }
994                let length =
995                    u32::try_from(text_bytes.len()).context("VNC clipboard length exceeds u32")?;
996
997                let mut msg = vec![
998                    6u8, // ClientCutText
999                    0,
1000                    0,
1001                    0, // padding
1002                    (length >> 24) as u8,
1003                    (length >> 16) as u8,
1004                    (length >> 8) as u8,
1005                    (length & 0xff) as u8,
1006                ];
1007                msg.extend_from_slice(text_bytes);
1008                writer.write_all(&msg).await?;
1009            }
1010            _ => {
1011                warn!("Unknown VNC action: {}", action_name);
1012            }
1013        }
1014
1015        Ok(())
1016    }
1017}
1018
1019/// Read a model-supplied RFB coordinate, refusing anything the field cannot hold.
1020///
1021/// Every one of these was `data["x"].as_u64().unwrap_or(0) as u16`. RFB's x, y, width and
1022/// height are two bytes each, so `as u16` silently rewrites the number: **`65736` becomes
1023/// `200`** and `70000` becomes `4464`. The client then asks the server for a rectangle nobody
1024/// named, and nothing anywhere records that the request differs from the answer — the same
1025/// arithmetic fail-open `tests/narrowing_cast_drift_test.rs` exists for.
1026///
1027/// Refusing beats clamping: 65535 is not what the model asked for either, and the message is
1028/// what the repair loop reads.
1029fn rfb_u16(data: &JsonValue, key: &str, default: u16) -> Result<u16> {
1030    match data.get(key) {
1031        None | Some(JsonValue::Null) => Ok(default),
1032        Some(value) => {
1033            let raw = value.as_u64().ok_or_else(|| {
1034                anyhow::anyhow!("'{key}' must be a non-negative whole number, got {value}")
1035            })?;
1036            u16::try_from(raw).map_err(|_| {
1037                anyhow::anyhow!(
1038                    "'{key}' is {raw}; RFB carries coordinates and extents in two bytes, so \
1039                     0-65535 is the whole of what this field can say"
1040                )
1041            })
1042        }
1043    }
1044}
1045
1046/// The same for a one-byte RFB field (the pointer button mask).
1047///
1048/// RFB 3.8 defines bits 0-7, so `257` is not "button 1 again" — it is a number the field cannot
1049/// hold, and `as u8` turned it into 1.
1050fn rfb_u8(data: &JsonValue, key: &str, default: u8) -> Result<u8> {
1051    match data.get(key) {
1052        None | Some(JsonValue::Null) => Ok(default),
1053        Some(value) => {
1054            let raw = value.as_u64().ok_or_else(|| {
1055                anyhow::anyhow!("'{key}' must be a non-negative whole number, got {value}")
1056            })?;
1057            u8::try_from(raw).map_err(|_| {
1058                anyhow::anyhow!(
1059                    "'{key}' is {raw}; the RFB button mask is one byte (0-255), one bit per \
1060                     button"
1061                )
1062            })
1063        }
1064    }
1065}