Skip to main content

netget/server/vnc/
mod.rs

1//! VNC (Virtual Network Computing) server — RFB 3.8 (RFC 6143) over TCP.
2//!
3//! Hand-written RFB rather than a crate: the server half of RFB 3.8 is a version exchange, a
4//! security-type negotiation, ClientInit/ServerInit and seven fixed-length client messages, and
5//! no maintained Rust crate implements the *server* side. See `src/server/vnc/CLAUDE.md`.
6//!
7//! Rust owns the protocol, the framebuffer and the pixel encoding; the LLM owns what is on
8//! screen. Four events are raised from the message loop — `vnc_framebuffer_update_request`,
9//! `vnc_key_event`, `vnc_pointer_event` and `vnc_client_cut_text` — and the model answers with
10//! structured drawing commands (never pixels), clipboard text, "nothing changed", or a
11//! disconnect. `crate::display::DisplayCanvas` rasterises the commands; this module encodes the
12//! result as a Raw-encoded rectangle.
13//!
14//! Security is "None" (type 1) only: any client that connects is admitted. There is no VNC-Auth
15//! and no password.
16
17pub mod actions;
18
19use crate::display::{Color, DisplayCanvas, DisplayCommand};
20use crate::llm::action_helper::call_llm;
21use crate::llm::actions::protocol_trait::ActionResult;
22use crate::llm::ollama_client::OllamaClient;
23use crate::logging::emit::{Log, Sink};
24use crate::protocol::Event;
25use crate::server::connection::ConnectionId;
26use crate::state::app_state::AppState;
27use crate::state::server::{ConnectionState, ConnectionStatus, ProtocolConnectionInfo};
28use actions::{
29    VncProtocol, RESULT_CLIPBOARD, RESULT_DISPLAY, RESULT_NO_CHANGE, VNC_CLIENT_CUT_TEXT_EVENT,
30    VNC_FRAMEBUFFER_UPDATE_REQUEST_EVENT, VNC_KEY_EVENT, VNC_POINTER_EVENT,
31};
32use anyhow::{anyhow, Context, Result};
33use std::net::SocketAddr;
34use std::sync::Arc;
35use std::time::Duration;
36use tokio::io::{AsyncReadExt, AsyncWriteExt};
37use tokio::net::TcpStream;
38use tokio::sync::mpsc;
39use tracing::{debug, error, trace, warn};
40
41/// How long to wait for the first byte from a peer that has only connected.
42///
43/// RFB is server-speaks-first: this server writes `RFB 003.008\n` and the viewer answers with
44/// its own twelve-byte version string from inside its connect path, before any human is
45/// involved. Thirty seconds is far longer than that takes and is the same bound the rest of
46/// this tree gives a peer that has produced nothing.
47const FIRST_BYTE_READ_TIMEOUT: Duration = Duration::from_secs(30);
48
49/// How long to wait for further bytes once the peer has sent something.
50///
51/// Half an hour, and the reason it cannot be short is a property of RFB rather than taste: a
52/// viewer that has issued an *incremental* `FramebufferUpdateRequest` is required to say
53/// nothing more until the server has an update to send it, so a perfectly healthy client
54/// watching an unchanging screen is silent for as long as the screen does not change. Closing
55/// that would be the TFTP mistake this project already made once — measuring "idle" on a
56/// connection that is in the middle of the protocol's own normal behaviour.
57///
58/// A bound is still necessary, and here more than elsewhere: this server offers security type
59/// `None`, so every connection is unauthenticated by construction. Thirty minutes is longer
60/// than any interval an operator leaves a viewer attached across and still turns "hold a socket
61/// forever" into "hold it for half an hour".
62const IDLE_BETWEEN_MESSAGES_TIMEOUT: Duration = Duration::from_secs(1800);
63
64/// Concurrent connections this server admits.
65///
66/// [`crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS`]. Each connection carries a
67/// framebuffer and may buffer a `ClientCutText` up to [`MAX_CUT_TEXT_LEN`], so this is the
68/// multiplier that turns those per-connection bounds into total ones.
69const MAX_CONNECTIONS: usize = crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS;
70
71/// What a peer over [`MAX_CONNECTIONS`] is told before the socket closes: nothing.
72///
73/// RFB *has* a refusal — a security-type count of zero followed by a reason string — but it is
74/// not reachable here. It may only be sent after the peer has returned its own ProtocolVersion,
75/// and a capped connection is refused before a single byte has been read. Writing
76/// `RFB 003.008\n` and then hanging up would be worse than silence: it starts a handshake this
77/// server has already decided to decline, and the viewer reports a truncated connection rather
78/// than a refused one. So the peer gets a clean EOF and the refusal is recorded where it can
79/// carry a reason — `accept_bounded` logs it at WARN with
80/// `decision=fail_closed_connection_cap`.
81const CONNECTION_CAP_REFUSAL: &[u8] = b"";
82
83/// The read half of a VNC connection, with both deadlines applied to every read.
84///
85/// [`crate::server::accept_bounded::IdleTimeoutReader`] rather than a `tokio::time::timeout`
86/// around one call, because RFB reads are scattered: the version exchange, the security choice,
87/// `ClientInit`, each message-type octet and each message body are separate reads, and a peer
88/// that sends a message type and then stalls mid-body would otherwise be unbounded. Its
89/// deadline is armed lazily — only while a read is actually pending — so the model round-trip
90/// inside a message handler, and a `manual` rule parking an event for a human, run with no
91/// clock against them at all.
92type VncReader = crate::server::accept_bounded::IdleTimeoutReader<tokio::io::ReadHalf<TcpStream>>;
93
94/// VNC server whose display contents are decided by the LLM.
95pub struct VncServer;
96
97/// RFB protocol version this server speaks.
98const RFB_VERSION: &[u8] = b"RFB 003.008\n";
99
100/// Default desktop name announced in ServerInit.
101const DEFAULT_DESKTOP_NAME: &str = "NetGet VNC";
102
103/// Largest ClientCutText payload accepted, in bytes, and this server's declared
104/// `max_inbound_bytes`.
105///
106/// The length field is a client-controlled u32 and the buffer for it is allocated before a
107/// single byte of payload is read; without a cap a nine-byte message asks for a 4 GiB
108/// allocation. It is the only client message RFB lets a peer size: every other one is fixed
109/// length, and SetEncodings' u16 count is read four bytes at a time and discarded, never
110/// buffered. Over the cap the connection closes — the payload cannot be skipped without
111/// reading it, and RFB has no error message to send once the session is up.
112pub const MAX_CUT_TEXT_LEN: u32 = 1 << 20;
113
114/// Background of the placeholder screen.
115///
116/// The placeholder is what a client sees when the model gave no usable answer — an LLM error, a
117/// batch in which every action failed, or an empty action list. It is deliberately *not* the
118/// same as any screen the model can ask for by accident, and it is deliberately not silence:
119/// RFB has no way to say "no answer", so a client left waiting on a full update request simply
120/// hangs. `tests/server/vnc/test.rs` asserts this exact colour on the no-answer path.
121pub const PLACEHOLDER_BACKGROUND: Color = Color::rgb(24, 26, 34);
122
123/// Foreground of the placeholder screen's diagnostic line.
124const PLACEHOLDER_FOREGROUND: Color = Color::rgb(203, 213, 225);
125
126/// Security types offered in the handshake.
127#[derive(Debug, Clone, Copy, PartialEq, Eq)]
128#[repr(u8)]
129enum SecurityType {
130    None = 1,
131}
132
133/// VNC pixel format
134#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
135pub struct VncPixelFormat {
136    pub bits_per_pixel: u8,
137    pub depth: u8,
138    pub big_endian: bool,
139    pub true_color: bool,
140    pub red_max: u16,
141    pub green_max: u16,
142    pub blue_max: u16,
143    pub red_shift: u8,
144    pub green_shift: u8,
145    pub blue_shift: u8,
146}
147
148impl VncPixelFormat {
149    /// The only format this server sends: 32bpp little-endian BGRX.
150    pub fn default_rgb888() -> Self {
151        Self {
152            bits_per_pixel: 32,
153            depth: 24,
154            big_endian: false,
155            true_color: true,
156            red_max: 255,
157            green_max: 255,
158            blue_max: 255,
159            red_shift: 16,
160            green_shift: 8,
161            blue_shift: 0,
162        }
163    }
164}
165
166/// What the model said in answer to one event.
167///
168/// The three outcomes are kept apart on purpose. `redraw` is a decision to change the screen,
169/// `no_change` is an explicit decision to leave it alone, and `no_answer` is the model failing
170/// to say anything usable. Collapsing the last two — treating silence as "nothing changed" —
171/// is the fail-open shape this codebase has been bitten by before: a viewer would sit on a
172/// blank window with no indication that the model never answered.
173#[derive(Default)]
174struct Decision {
175    redraw: Option<Vec<DisplayCommand>>,
176    clipboard: Vec<String>,
177    no_change: bool,
178    close: bool,
179    /// Why no usable answer arrived, if none did.
180    no_answer: Option<String>,
181}
182
183/// Per-connection state for one VNC client.
184struct VncConnection {
185    connection_id: ConnectionId,
186    server_id: crate::state::ServerId,
187    width: u16,
188    height: u16,
189    app_state: Arc<AppState>,
190    llm_client: OllamaClient,
191    protocol: VncProtocol,
192    status_tx: mpsc::UnboundedSender<String>,
193    write_half: Arc<tokio::sync::Mutex<tokio::io::WriteHalf<TcpStream>>>,
194    /// Most recently rendered frame, BGRX, `width * height * 4` bytes.
195    last_frame: Option<Vec<u8>>,
196    /// True when `last_frame` has changed since the client last received it.
197    dirty: bool,
198    /// True while the client has a FramebufferUpdateRequest this server has not answered.
199    pending_request: bool,
200    /// False until the first framebuffer request has been answered.
201    answered_first_request: bool,
202    /// Button mask carried by the previous PointerEvent, to detect press/release.
203    last_button_mask: u8,
204}
205
206impl VncServer {
207    /// Spawn the VNC server.
208    ///
209    /// `width`/`height` are validated by `actions::validate_framebuffer_size` before this is
210    /// called, so they are within `MIN_FRAMEBUFFER_DIMENSION..=MAX_FRAMEBUFFER_DIMENSION`.
211    #[allow(clippy::too_many_arguments)]
212    pub async fn spawn_with_llm_actions(
213        listen_addr: SocketAddr,
214        llm_client: OllamaClient,
215        app_state: Arc<AppState>,
216        status_tx: mpsc::UnboundedSender<String>,
217        server_id: crate::state::ServerId,
218        width: u16,
219        height: u16,
220        desktop_name: Option<String>,
221    ) -> Result<SocketAddr> {
222        let listener =
223            crate::server::socket_helpers::create_reusable_tcp_listener(listen_addr).await?;
224        let local_addr = listener.local_addr()?;
225        let desktop_name =
226            Arc::new(desktop_name.unwrap_or_else(|| DEFAULT_DESKTOP_NAME.to_string()));
227
228        // Keep the address at the end of this line: the E2E harness parses the port out of
229        // everything after the last "on ".
230        Log::new(Some(&status_tx)).info(format!("VNC server listening on {}", local_addr));
231        Log::new(Some(&status_tx)).debug(format!(
232            "VNC framebuffer {}x{}, desktop name '{}'",
233            width, height, desktop_name
234        ));
235
236        let task_registrar = app_state.clone();
237        let limiter = crate::server::accept_bounded::ConnectionLimiter::new(MAX_CONNECTIONS);
238        let accept_handle = tokio::spawn(async move {
239            loop {
240                match crate::server::accept_bounded::accept_bounded(
241                    &listener,
242                    &limiter,
243                    CONNECTION_CAP_REFUSAL,
244                    "VNC",
245                    Some(&status_tx),
246                )
247                .await
248                {
249                    Ok((stream, remote_addr, permit)) => {
250                        let connection_id =
251                            ConnectionId::new(app_state.get_next_unified_id().await);
252                        let local_addr_conn = stream.local_addr().unwrap_or(local_addr);
253                        let state_clone = app_state.clone();
254                        let status_clone = status_tx.clone();
255                        let llm_clone = llm_client.clone();
256                        let name_clone = desktop_name.clone();
257
258                        Log::new(Some(&status_clone))
259                            .info(format!("VNC client connected from {}", remote_addr));
260
261                        // Tracked, not detached: stop_server must abort this task too.
262                        let task_owner = app_state.clone();
263                        task_owner
264                            .spawn_server_task(server_id, async move {
265                                // Held for the life of the connection: releasing it here would
266                                // cap the accept rate rather than the number of live
267                                // connections.
268                                let _permit = permit;
269                                if let Err(e) = Self::handle_connection(
270                                    stream,
271                                    connection_id,
272                                    remote_addr,
273                                    local_addr_conn,
274                                    server_id,
275                                    state_clone,
276                                    status_clone,
277                                    llm_clone,
278                                    width,
279                                    height,
280                                    &name_clone,
281                                )
282                                .await
283                                {
284                                    error!("VNC connection error: {}", e);
285                                }
286                            })
287                            .await;
288                    }
289                    Err(e) => {
290                        // Break rather than continue: a persistent accept error (EMFILE, the
291                        // socket being closed under us) previously spun this loop at full CPU
292                        // forever, logging on every iteration.
293                        Log::new(Some(&status_tx))
294                            .error(format!("VNC accept failed, stopping loop: {}", e));
295                        break;
296                    }
297                }
298            }
299        });
300
301        task_registrar
302            .register_server_task(server_id, accept_handle)
303            .await;
304
305        Ok(local_addr)
306    }
307
308    /// Handle a single VNC connection: handshake, init, then the message loop.
309    #[allow(clippy::too_many_arguments)]
310    async fn handle_connection(
311        stream: TcpStream,
312        connection_id: ConnectionId,
313        remote_addr: SocketAddr,
314        local_addr: SocketAddr,
315        server_id: crate::state::ServerId,
316        app_state: Arc<AppState>,
317        status_tx: mpsc::UnboundedSender<String>,
318        llm_client: OllamaClient,
319        width: u16,
320        height: u16,
321        desktop_name: &str,
322    ) -> Result<()> {
323        let (read_half, write_half) = tokio::io::split(stream);
324        // Every read below goes through the deadline pair; nothing else does.
325        let mut read_half: VncReader = crate::server::accept_bounded::IdleTimeoutReader::with_first(
326            read_half,
327            FIRST_BYTE_READ_TIMEOUT,
328            IDLE_BETWEEN_MESSAGES_TIMEOUT,
329        );
330        let write_half = Arc::new(tokio::sync::Mutex::new(write_half));
331
332        let now = crate::utils::clock::Instant::now();
333        let conn_state = ConnectionState {
334            id: connection_id,
335            remote_addr,
336            local_addr,
337            bytes_sent: 0,
338            bytes_received: 0,
339            packets_sent: 0,
340            packets_received: 0,
341            last_activity: now,
342            status: ConnectionStatus::Active,
343            status_changed_at: now,
344            protocol_info: ProtocolConnectionInfo::empty(),
345        };
346        app_state
347            .add_connection_to_server(server_id, conn_state)
348            .await;
349        let _ = status_tx.send("__UPDATE_UI__".to_string());
350
351        // Peer messaging: the dashboard's "message this peer" / "disconnect this peer" inject
352        // actions into THIS connection through the same executor the LLM path uses. Registered
353        // before the handshake so the connection is reachable the instant it appears in the rail.
354        //
355        // Only `vnc_disconnect_client` (which returns `ActionResult::CloseConnection`) is
356        // executed by the generic peer task: it half-closes the write side and the message loop
357        // reads EOF. The drawing verbs (`vnc_render_display`, `vnc_set_clipboard`) return
358        // `ActionResult::Custom` and are reported as "executed" without touching the wire,
359        // because rendering needs this connection's framebuffer state, which lives in the
360        // message loop. See `src/server/vnc/CLAUDE.md`.
361        let peer_protocol: Arc<dyn crate::llm::actions::protocol_trait::Server> =
362            Arc::new(VncProtocol::new());
363        let peer_rx = crate::server::peer_support::register_peer_channel(
364            &app_state,
365            server_id,
366            connection_id.as_u32(),
367        )
368        .await;
369        crate::server::peer_support::spawn_peer_command_task(
370            peer_rx,
371            peer_protocol,
372            app_state.clone(),
373            server_id,
374            connection_id.as_u32(),
375            write_half.clone(),
376            status_tx.clone(),
377        );
378
379        let result = async {
380            Self::perform_handshake(&mut read_half, &write_half, &status_tx).await?;
381
382            Log::new(Some(&status_tx)).debug(format!("VNC handshake complete for {}", remote_addr));
383            app_state
384                .update_vnc_connection_auth(server_id, connection_id, true, None)
385                .await;
386
387            Self::handle_client_init(
388                &mut read_half,
389                &write_half,
390                &status_tx,
391                width,
392                height,
393                desktop_name,
394            )
395            .await?;
396
397            let mut connection = VncConnection {
398                connection_id,
399                server_id,
400                width,
401                height,
402                app_state: app_state.clone(),
403                llm_client,
404                protocol: VncProtocol::new(),
405                status_tx: status_tx.clone(),
406                write_half: write_half.clone(),
407                last_frame: None,
408                dirty: false,
409                pending_request: false,
410                answered_first_request: false,
411                last_button_mask: 0,
412            };
413            connection.message_loop(read_half).await
414        }
415        .await;
416
417        // Always drop the connection from the server view, whatever went wrong above; the
418        // TUI otherwise shows a connection that no longer exists. Remove the peer handle on the
419        // same exit path (EOF, read error, unknown message, injected disconnect) so the rail
420        // stops offering "message this peer" for a connection that is gone; the peer task also
421        // removes it on an injected disconnect, and both calls are idempotent.
422        app_state
423            .remove_peer_handle(server_id, connection_id.as_u32())
424            .await;
425        app_state
426            .remove_connection_from_server(server_id, connection_id)
427            .await;
428        let _ = status_tx.send("__UPDATE_UI__".to_string());
429
430        result
431    }
432
433    /// RFB 3.8 handshake: ProtocolVersion, security types, SecurityResult.
434    async fn perform_handshake(
435        read_half: &mut VncReader,
436        write_half: &Arc<tokio::sync::Mutex<tokio::io::WriteHalf<TcpStream>>>,
437        status_tx: &mpsc::UnboundedSender<String>,
438    ) -> Result<()> {
439        {
440            let mut writer = write_half.lock().await;
441            writer.write_all(RFB_VERSION).await?;
442            writer.flush().await?;
443        }
444        trace!(
445            "Sent RFB version: {}",
446            String::from_utf8_lossy(RFB_VERSION).trim()
447        );
448
449        let mut client_version = [0u8; 12];
450        read_half.read_exact(&mut client_version).await?;
451        trace!(
452            "Received client version: {}",
453            String::from_utf8_lossy(&client_version).trim()
454        );
455
456        {
457            let mut writer = write_half.lock().await;
458            writer.write_u8(1).await?; // Number of security types
459            writer.write_u8(SecurityType::None as u8).await?;
460            writer.flush().await?;
461        }
462        trace!("Sent security types: [None]");
463
464        let chosen_security = read_half.read_u8().await?;
465        trace!("Client chose security type: {}", chosen_security);
466
467        if chosen_security == SecurityType::None as u8 {
468            let mut writer = write_half.lock().await;
469            writer.write_u32(0).await?; // 0 = OK
470            writer.flush().await?;
471            trace!("Sent SecurityResult: OK");
472            Log::new(Some(status_tx)).debug("VNC client authenticated");
473            Ok(())
474        } else {
475            // RFB 3.8 requires a reason string after a failed SecurityResult, and that string
476            // is rendered verbatim by the peer's viewer. It is a byte literal for exactly that
477            // reason: nothing from NetGet's own diagnostics may reach it.
478            let reason = b"only security type 1 (None) is supported";
479            {
480                let mut writer = write_half.lock().await;
481                writer.write_u32(1).await?; // 1 = Failed
482                writer.write_u32(reason.len() as u32).await?;
483                writer.write_all(reason).await?;
484                writer.flush().await?;
485            }
486            // The protocol refused this peer, not the model — no model was consulted. Tagged
487            // so it is never read as a fail-closed answer to an event.
488            Log::new(Some(status_tx)).warn(format!(
489                "VNC handshake refused decision=protocol_error: client requested unsupported \
490                 security type {}",
491                chosen_security
492            ));
493            Err(anyhow!(
494                "client requested unsupported security type {chosen_security}"
495            ))
496        }
497    }
498
499    /// ClientInit (shared flag) then ServerInit (geometry, pixel format, desktop name).
500    async fn handle_client_init(
501        read_half: &mut VncReader,
502        write_half: &Arc<tokio::sync::Mutex<tokio::io::WriteHalf<TcpStream>>>,
503        status_tx: &mpsc::UnboundedSender<String>,
504        width: u16,
505        height: u16,
506        desktop_name: &str,
507    ) -> Result<()> {
508        let shared_flag = read_half.read_u8().await?;
509        trace!("Client shared flag: {}", shared_flag);
510
511        let pixel_format = VncPixelFormat::default_rgb888();
512        let name = desktop_name.as_bytes();
513
514        let mut message = Vec::with_capacity(24 + name.len());
515        message.extend_from_slice(&width.to_be_bytes());
516        message.extend_from_slice(&height.to_be_bytes());
517        message.push(pixel_format.bits_per_pixel);
518        message.push(pixel_format.depth);
519        message.push(u8::from(pixel_format.big_endian));
520        message.push(u8::from(pixel_format.true_color));
521        message.extend_from_slice(&pixel_format.red_max.to_be_bytes());
522        message.extend_from_slice(&pixel_format.green_max.to_be_bytes());
523        message.extend_from_slice(&pixel_format.blue_max.to_be_bytes());
524        message.push(pixel_format.red_shift);
525        message.push(pixel_format.green_shift);
526        message.push(pixel_format.blue_shift);
527        message.extend_from_slice(&[0, 0, 0]); // Padding
528        message.extend_from_slice(&(name.len() as u32).to_be_bytes());
529        message.extend_from_slice(name);
530
531        {
532            let mut writer = write_half.lock().await;
533            writer.write_all(&message).await?;
534            writer.flush().await?;
535        }
536
537        Log::new(Some(status_tx)).debug(format!(
538            "VNC initialized: {}x{} framebuffer '{}'",
539            width, height, desktop_name
540        ));
541
542        Ok(())
543    }
544}
545
546impl VncConnection {
547    /// Main client-message loop.
548    ///
549    /// One connection is handled strictly sequentially: a model call for one message finishes
550    /// before the next message is read. RFB client messages are small and the socket buffers
551    /// them, so nothing is lost; the cost is latency on input that arrives during a call.
552    async fn message_loop(&mut self, mut read_half: VncReader) -> Result<()> {
553        loop {
554            let message_type = match read_half.read_u8().await {
555                Ok(t) => t,
556                Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
557                    Log::new(Some(&self.status_tx)).info("VNC client disconnected");
558                    break;
559                }
560                Err(e) => return Err(e.into()),
561            };
562
563            trace!("Received message type: {}", message_type);
564
565            // Bytes consumed for this message, starting with the type byte already read. Fed to
566            // `update_connection_stats` so the rail's `↓` counter and `last_activity` are live.
567            let mut bytes_in: u64 = 1;
568
569            let keep_going = match message_type {
570                0 => {
571                    // SetPixelFormat. Read and discarded: the server always sends 32bpp BGRX,
572                    // so a client that requests anything else will render garbage.
573                    let mut buf = [0u8; 19]; // 3 padding + 16 pixel format
574                    read_half.read_exact(&mut buf).await?;
575                    bytes_in += 19;
576                    trace!("SetPixelFormat received (ignored, server always sends 32bpp BGRX)");
577                    true
578                }
579                2 => {
580                    // SetEncodings. Raw is the only encoding this server produces, and RFB
581                    // requires every client to support it, so the list is parsed for framing
582                    // and discarded.
583                    let _padding = read_half.read_u8().await?;
584                    let num_encodings = read_half.read_u16().await?;
585                    for _ in 0..num_encodings {
586                        let _ = read_half.read_i32().await?;
587                    }
588                    bytes_in += 3 + 4 * num_encodings as u64;
589                    trace!("SetEncodings received: {} encodings", num_encodings);
590                    true
591                }
592                3 => {
593                    let incremental = read_half.read_u8().await? != 0;
594                    let x = read_half.read_u16().await?;
595                    let y = read_half.read_u16().await?;
596                    let width = read_half.read_u16().await?;
597                    let height = read_half.read_u16().await?;
598                    bytes_in += 9;
599                    trace!(
600                        "FramebufferUpdateRequest: incremental={}, x={}, y={}, w={}, h={}",
601                        incremental,
602                        x,
603                        y,
604                        width,
605                        height
606                    );
607                    self.handle_update_request(incremental).await?
608                }
609                4 => {
610                    let down = read_half.read_u8().await? != 0;
611                    let _padding = read_half.read_u16().await?;
612                    let keysym = read_half.read_u32().await?;
613                    bytes_in += 7;
614                    self.handle_key_event(down, keysym).await?
615                }
616                5 => {
617                    let button_mask = read_half.read_u8().await?;
618                    let x = read_half.read_u16().await?;
619                    let y = read_half.read_u16().await?;
620                    bytes_in += 5;
621                    self.handle_pointer_event(button_mask, x, y).await?
622                }
623                6 => {
624                    let _padding = read_half.read_u8().await?;
625                    let _padding = read_half.read_u16().await?;
626                    let length = read_half.read_u32().await?;
627                    bytes_in += 7;
628                    if length > MAX_CUT_TEXT_LEN {
629                        // The buffer used to be allocated straight from this client-controlled
630                        // u32, so a nine-byte message could ask for a 4 GiB allocation.
631                        Log::new(Some(&self.status_tx)).warn(format!(
632                            "VNC ClientCutText length {} exceeds {} byte cap \
633                             decision=fail_closed_cut_text_too_large, closing connection",
634                            length, MAX_CUT_TEXT_LEN
635                        ));
636                        false
637                    } else {
638                        let mut text = vec![0u8; length as usize];
639                        read_half.read_exact(&mut text).await?;
640                        bytes_in += length as u64;
641                        self.handle_cut_text(&text).await?
642                    }
643                }
644                other => {
645                    // The message length is unknown, so the stream cannot be resynchronised:
646                    // continuing would read this message's body as message types. Close.
647                    Log::new(Some(&self.status_tx)).warn(format!(
648                        "VNC client sent unknown message type {}, closing connection",
649                        other
650                    ));
651                    false
652                }
653            };
654
655            self.app_state
656                .update_connection_stats(
657                    self.server_id,
658                    self.connection_id,
659                    Some(bytes_in),
660                    None,
661                    Some(1),
662                    None,
663                )
664                .await;
665
666            if !keep_going {
667                break;
668            }
669        }
670
671        Ok(())
672    }
673
674    // ------------------------------------------------------------------
675    // Event handling
676    // ------------------------------------------------------------------
677
678    /// A FramebufferUpdateRequest arrived.
679    ///
680    /// Non-incremental requests ("send me the whole screen") consult the model. Incremental
681    /// ones ("tell me when something changes") do not: RFB permits the server to hold such a
682    /// request open until the screen actually changes, and answering each one with a model
683    /// call would spend a round-trip per poll on an idle desktop. A held request is answered
684    /// by the next event that redraws.
685    async fn handle_update_request(&mut self, incremental: bool) -> Result<bool> {
686        self.pending_request = true;
687
688        if incremental && self.answered_first_request {
689            if self.dirty {
690                self.send_pending_frame(false).await?;
691            } else {
692                trace!(
693                    "VNC incremental request held open for {} (screen unchanged)",
694                    self.connection_id
695                );
696            }
697            return Ok(true);
698        }
699
700        let event = Event::new(
701            &VNC_FRAMEBUFFER_UPDATE_REQUEST_EVENT,
702            serde_json::json!({
703                "width": self.width,
704                "height": self.height,
705                "first_request": !self.answered_first_request,
706            }),
707        );
708        let decision = self.consult(&event).await;
709        let close = self
710            .apply(decision, "the model gave no screen to draw")
711            .await?;
712
713        // A full update request must be answered: RFB has no "nothing to send" reply, so a
714        // client that asked for the whole screen and gets nothing waits forever.
715        self.send_pending_frame(true).await?;
716        self.answered_first_request = true;
717
718        Ok(!close)
719    }
720
721    /// A key went down or up.
722    async fn handle_key_event(&mut self, down: bool, keysym: u32) -> Result<bool> {
723        let key_name = keysym_name(keysym);
724        // FileOnly: the vnc_key_event template renders the equivalent line to the TUI.
725        Log::new(Some(&self.status_tx)).debug(format!(
726            "VNC KeyEvent: down={}, key={}, keysym={}",
727            down, key_name, keysym
728        ));
729
730        let event = Event::new(
731            &VNC_KEY_EVENT,
732            serde_json::json!({
733                "down": down,
734                "keysym": keysym,
735                "key": key_name,
736            }),
737        );
738        let decision = self.consult(&event).await;
739        let close = self
740            .apply(decision, "the model gave no screen to draw for this key")
741            .await?;
742        self.send_pending_frame(false).await?;
743        Ok(!close)
744    }
745
746    /// A pointer message arrived.
747    ///
748    /// Only a change in the button mask raises `vnc_pointer_event`. A viewer emits a
749    /// PointerEvent for every pixel of mouse travel, and one model round-trip per pixel is not
750    /// a feature; movement is dual-logged instead so it is still visible in the TUI and over
751    /// MCP, which is where somebody watching a session would look.
752    async fn handle_pointer_event(&mut self, button_mask: u8, x: u16, y: u16) -> Result<bool> {
753        let previous = self.last_button_mask;
754        self.last_button_mask = button_mask;
755
756        if button_mask == previous {
757            // Movement raises no event, so this DEBUG line is the only record of it:
758            // keep it on the TUI (Both) so input is never silently invisible.
759            Log::new(Some(&self.status_tx)).debug_to(
760                Sink::Both,
761                format!(
762                    "VNC PointerEvent: move to {},{} (buttons={:#04b})",
763                    x, y, button_mask
764                ),
765            );
766            return Ok(true);
767        }
768
769        let pressed = button_mask.count_ones() > previous.count_ones();
770        // FileOnly: a button change raises vnc_pointer_event, whose template renders
771        // the equivalent line to the TUI.
772        Log::new(Some(&self.status_tx)).debug(format!(
773            "VNC PointerEvent: {} at {},{} (buttons={:#04b})",
774            if pressed { "press" } else { "release" },
775            x,
776            y,
777            button_mask
778        ));
779
780        let event = Event::new(
781            &VNC_POINTER_EVENT,
782            serde_json::json!({
783                "x": x,
784                "y": y,
785                "pressed": pressed,
786                "buttons": button_names(button_mask),
787                "button_mask": button_mask,
788            }),
789        );
790        let decision = self.consult(&event).await;
791        let close = self
792            .apply(decision, "the model gave no screen to draw for this click")
793            .await?;
794        self.send_pending_frame(false).await?;
795        Ok(!close)
796    }
797
798    /// The client sent us its clipboard.
799    async fn handle_cut_text(&mut self, raw: &[u8]) -> Result<bool> {
800        // RFB says ClientCutText is Latin-1. Decoding byte-per-character can never fail, which
801        // matters: this is attacker-controlled input and must not be able to abort the task.
802        let text: String = raw.iter().map(|&b| b as char).collect();
803        // FileOnly: the vnc_client_cut_text template renders the equivalent line to the TUI.
804        Log::new(Some(&self.status_tx)).debug(format!(
805            "VNC ClientCutText: {} bytes from {}",
806            raw.len(),
807            self.connection_id
808        ));
809        trace!("VNC ClientCutText payload: {:?}", text);
810
811        let event = Event::new(
812            &VNC_CLIENT_CUT_TEXT_EVENT,
813            serde_json::json!({ "text": text }),
814        );
815        let decision = self.consult(&event).await;
816        // Clipboard text needs no screen: an event that only produced clipboard output is a
817        // complete answer, so no placeholder is drawn for it.
818        let close = self.apply(decision, "").await?;
819        self.send_pending_frame(false).await?;
820        Ok(!close)
821    }
822
823    // ------------------------------------------------------------------
824    // LLM plumbing
825    // ------------------------------------------------------------------
826
827    /// Ask the event handlers (script → static → LLM) what to do about `event`.
828    ///
829    /// Never returns an error: a failure here has to leave the connection alive so the caller
830    /// can put a placeholder on screen. The reason is carried in `Decision::no_answer`.
831    async fn consult(&self, event: &Event) -> Decision {
832        let mut decision = Decision::default();
833        let log = Log::new(Some(&self.status_tx));
834
835        let execution = match call_llm(
836            &self.llm_client,
837            &self.app_state,
838            self.server_id,
839            Some(self.connection_id),
840            event,
841            &self.protocol,
842        )
843        .await
844        {
845            Ok(execution) => execution,
846            Err(e) => {
847                // The backend failed. The peer gets the placeholder screen with a fixed
848                // caption; the error text stays here. `fail_closed_llm_overloaded` and
849                // `fail_closed_llm_error` are kept apart so a saturated backend and a dead
850                // one are not the same line, and neither can be mistaken for the model
851                // having chosen to leave the screen alone (`decision=model_answer` with a
852                // `vnc_no_change`).
853                let tag = match crate::utils::wire_failure::WireFailure::classify(&e) {
854                    crate::utils::wire_failure::WireFailure::Overloaded => {
855                        "fail_closed_llm_overloaded"
856                    }
857                    crate::utils::wire_failure::WireFailure::Unavailable => "fail_closed_llm_error",
858                };
859                log.error(format!(
860                    "VNC {} on {} not answered decision={}: {}",
861                    event.event_type.id, self.connection_id, tag, e
862                ));
863                decision.no_answer = Some(e.to_string());
864                return decision;
865            }
866        };
867
868        for message in execution.messages {
869            let _ = self.status_tx.send(message);
870        }
871
872        // Flatten `Multiple` so a batched answer is not silently ignored.
873        let mut flattened = Vec::with_capacity(execution.protocol_results.len());
874        flatten_results(execution.protocol_results, &mut flattened);
875
876        let mut saw_result = false;
877        for result in flattened {
878            match result {
879                ActionResult::Custom { name, data } if name == RESULT_DISPLAY => {
880                    saw_result = true;
881                    match serde_json::from_value::<Vec<DisplayCommand>>(
882                        data.get("commands").cloned().unwrap_or_default(),
883                    ) {
884                        Ok(commands) => decision.redraw = Some(commands),
885                        Err(e) => {
886                            // The action executor already validated this JSON, so reaching
887                            // here means the two disagree — a bug worth shouting about.
888                            error!("VNC render result could not be decoded: {}", e);
889                            decision.no_answer =
890                                Some(format!("render result could not be decoded: {e}"));
891                        }
892                    }
893                }
894                ActionResult::Custom { name, .. } if name == RESULT_NO_CHANGE => {
895                    saw_result = true;
896                    decision.no_change = true;
897                }
898                ActionResult::Custom { name, data } if name == RESULT_CLIPBOARD => {
899                    saw_result = true;
900                    if let Some(text) = data.get("text").and_then(|v| v.as_str()) {
901                        decision.clipboard.push(text.to_string());
902                    }
903                }
904                ActionResult::CloseConnection => {
905                    saw_result = true;
906                    decision.close = true;
907                }
908                ActionResult::NoAction => {}
909                other => {
910                    warn!("VNC ignoring unexpected action result: {:?}", other);
911                }
912            }
913        }
914
915        // Exactly one `decision=` line per event, whatever happened. Without it a model that
916        // refused, a model that said nothing, and an executor that rejected the model's own
917        // action are one indistinguishable "the screen did not change".
918        if decision.no_answer.is_some() {
919            // Only the render-decode branch above reaches here: the executor accepted the
920            // action and this module could not read its result, which is a bug in the pair.
921            log.error(format!(
922                "VNC {} on {} decision=fail_closed_bad_action: the render result could not be \
923                 decoded",
924                event.event_type.id, self.connection_id
925            ));
926        } else if !saw_result {
927            let detail = if execution.failures.is_empty() {
928                "no protocol action was returned".to_string()
929            } else {
930                execution
931                    .failures
932                    .iter()
933                    .map(|f| format!("{}: {}", f.action, f.error))
934                    .collect::<Vec<_>>()
935                    .join("; ")
936            };
937            // An empty answer is the model's own silence (WARN); an answer whose actions the
938            // executor refused is a malformed answer (ERROR). Both draw the placeholder.
939            if execution.failures.is_empty() {
940                log.warn(format!(
941                    "VNC {} on {} produced no usable action decision=model_silent ({})",
942                    event.event_type.id, self.connection_id, detail
943                ));
944            } else {
945                log.error(format!(
946                    "VNC {} on {} produced no usable action decision=fail_closed_bad_action ({})",
947                    event.event_type.id, self.connection_id, detail
948                ));
949            }
950            decision.no_answer = Some(detail);
951        } else if decision.close {
952            log.info(format!(
953                "VNC {} on {} decision=model_reject (the model closed the connection)",
954                event.event_type.id, self.connection_id
955            ));
956        } else {
957            log.info(format!(
958                "VNC {} on {} decision=model_answer",
959                event.event_type.id, self.connection_id
960            ));
961        }
962
963        decision
964    }
965
966    /// Apply a decision: render, push clipboard text, or draw the placeholder.
967    ///
968    /// Returns true when the connection should close. `no_answer_reason` empty means this event
969    /// does not need a screen, so a missing render is not a failure.
970    async fn apply(&mut self, decision: Decision, no_answer_reason: &str) -> Result<bool> {
971        for text in &decision.clipboard {
972            self.send_server_cut_text(text).await?;
973        }
974
975        if let Some(commands) = decision.redraw {
976            let pixels = render_frame(self.width, self.height, commands).await?;
977            self.store_frame(pixels);
978        } else if decision.no_change {
979            trace!(
980                "VNC screen held unchanged for {} by explicit decision",
981                self.connection_id
982            );
983        } else if decision.no_answer.is_some() && !no_answer_reason.is_empty() {
984            // Structurally distinct from `vnc_no_change`: the model saying "leave the screen
985            // alone" keeps whatever is there, while no answer at all puts a placeholder up
986            // that says so.
987            let pixels = render_frame(
988                self.width,
989                self.height,
990                placeholder_commands(self.height, no_answer_reason),
991            )
992            .await?;
993            self.store_frame(pixels);
994        }
995
996        Ok(decision.close)
997    }
998
999    /// Record a freshly rendered frame as the current screen.
1000    fn store_frame(&mut self, pixels: Vec<u8>) {
1001        self.last_frame = Some(pixels);
1002        self.dirty = true;
1003    }
1004
1005    /// Answer an outstanding FramebufferUpdateRequest, if there is one.
1006    ///
1007    /// `force` answers even when nothing changed, which a non-incremental request requires.
1008    /// Otherwise an unchanged screen leaves the request open, exactly as a real VNC server
1009    /// does, and it is answered by the next redraw.
1010    async fn send_pending_frame(&mut self, force: bool) -> Result<()> {
1011        if !self.pending_request || !(force || self.dirty) {
1012            return Ok(());
1013        }
1014
1015        if self.last_frame.is_none() {
1016            // Nothing has ever been drawn and the client is waiting on a full update.
1017            let pixels = render_frame(
1018                self.width,
1019                self.height,
1020                placeholder_commands(self.height, "no screen has been drawn yet"),
1021            )
1022            .await?;
1023            self.store_frame(pixels);
1024        }
1025
1026        let Some(pixels) = self.last_frame.as_ref() else {
1027            return Ok(());
1028        };
1029
1030        let mut frame = framebuffer_update_header(self.width, self.height);
1031        frame.extend_from_slice(pixels);
1032
1033        let frame_len = frame.len();
1034        {
1035            let mut writer = self.write_half.lock().await;
1036            writer.write_all(&frame).await?;
1037            writer.flush().await?;
1038        }
1039        self.app_state
1040            .update_connection_stats(
1041                self.server_id,
1042                self.connection_id,
1043                None,
1044                Some(frame_len as u64),
1045                None,
1046                Some(1),
1047            )
1048            .await;
1049
1050        self.pending_request = false;
1051        self.dirty = false;
1052        // Send summary FileOnly: the vnc_render_display action template already reports
1053        // the render to the TUI.
1054        Log::new(Some(&self.status_tx)).debug(format!(
1055            "VNC sent {}x{} framebuffer update ({} bytes) to {}",
1056            self.width,
1057            self.height,
1058            frame.len(),
1059            self.connection_id
1060        ));
1061        Ok(())
1062    }
1063
1064    /// Push text to the client's clipboard (RFB ServerCutText, message type 3).
1065    async fn send_server_cut_text(&self, text: &str) -> Result<()> {
1066        // RFB ServerCutText is Latin-1; anything outside it has no representation on the wire.
1067        let latin1: Vec<u8> = text
1068            .chars()
1069            .map(|c| if (c as u32) <= 0xFF { c as u8 } else { b'?' })
1070            .collect();
1071
1072        let mut message = Vec::with_capacity(8 + latin1.len());
1073        message.push(3); // ServerCutText
1074        message.extend_from_slice(&[0, 0, 0]); // Padding
1075        message.extend_from_slice(&(latin1.len() as u32).to_be_bytes());
1076        message.extend_from_slice(&latin1);
1077
1078        let message_len = message.len();
1079        {
1080            let mut writer = self.write_half.lock().await;
1081            writer.write_all(&message).await?;
1082            writer.flush().await?;
1083        }
1084        self.app_state
1085            .update_connection_stats(
1086                self.server_id,
1087                self.connection_id,
1088                None,
1089                Some(message_len as u64),
1090                None,
1091                Some(1),
1092            )
1093            .await;
1094        // Send summary FileOnly: the vnc_set_clipboard action template already reports
1095        // the send to the TUI.
1096        Log::new(Some(&self.status_tx)).debug(format!(
1097            "VNC sent {} bytes of clipboard text to {}",
1098            latin1.len(),
1099            self.connection_id
1100        ));
1101        Ok(())
1102    }
1103}
1104
1105/// Flatten nested [`ActionResult::Multiple`] into a single ordered list.
1106fn flatten_results(results: Vec<ActionResult>, out: &mut Vec<ActionResult>) {
1107    for result in results {
1108        match result {
1109            ActionResult::Multiple(inner) => flatten_results(inner, out),
1110            other => out.push(other),
1111        }
1112    }
1113}
1114
1115/// The RFB FramebufferUpdate header for a single full-framebuffer Raw rectangle.
1116///
1117/// The client's requested region is deliberately not echoed back: RFB lets the server answer
1118/// with any rectangle, and answering with the client's extent (as this code used to) both
1119/// contradicts the size announced in ServerInit and lets a client ask for 65535x65535.
1120fn framebuffer_update_header(width: u16, height: u16) -> Vec<u8> {
1121    let mut header = Vec::with_capacity(16);
1122    header.push(0); // Message type: FramebufferUpdate
1123    header.push(0); // Padding
1124    header.extend_from_slice(&1u16.to_be_bytes()); // Number of rectangles
1125    header.extend_from_slice(&0u16.to_be_bytes()); // X position
1126    header.extend_from_slice(&0u16.to_be_bytes()); // Y position
1127    header.extend_from_slice(&width.to_be_bytes());
1128    header.extend_from_slice(&height.to_be_bytes());
1129    header.extend_from_slice(&0i32.to_be_bytes()); // Encoding: Raw
1130    header
1131}
1132
1133/// Rasterise display commands into the BGRX pixel payload of a Raw rectangle.
1134///
1135/// Rendering is CPU-bound (tiny-skia, plus font shaping on the first text draw), so it runs on
1136/// the blocking pool rather than stalling a tokio worker. A panic inside the canvas surfaces
1137/// here as an error instead of silently killing the connection task.
1138async fn render_frame(width: u16, height: u16, commands: Vec<DisplayCommand>) -> Result<Vec<u8>> {
1139    let (w, h) = (width as u32, height as u32);
1140    let pixels = tokio::task::spawn_blocking(move || -> Result<Vec<u8>> {
1141        let mut canvas = DisplayCanvas::new(w, h);
1142        canvas.add_commands(commands);
1143        let image = canvas
1144            .try_render()
1145            .context("Invalid VNC display commands")?;
1146        let mut out = Vec::new();
1147        out.try_reserve_exact(w as usize * h as usize * 4)
1148            .context("Unable to allocate VNC framebuffer")?;
1149        for pixel in image.pixels() {
1150            out.extend_from_slice(&[pixel[2], pixel[1], pixel[0], 0]); // BGRX
1151        }
1152        Ok(out)
1153    })
1154    .await
1155    .context("VNC framebuffer rendering failed")??;
1156
1157    debug!("Rendered VNC framebuffer: {}x{}", width, height);
1158    Ok(pixels)
1159}
1160
1161/// The screen shown when no usable answer arrived.
1162fn placeholder_commands(height: u16, reason: &str) -> Vec<DisplayCommand> {
1163    vec![
1164        DisplayCommand::SetBackground {
1165            color: PLACEHOLDER_BACKGROUND,
1166        },
1167        DisplayCommand::DrawText {
1168            x: 24,
1169            y: (height / 2) as u32,
1170            text: format!("NetGet VNC: {reason}"),
1171            font_size: 18,
1172            color: PLACEHOLDER_FOREGROUND,
1173        },
1174    ]
1175}
1176
1177/// Names of the buttons held in an RFB button mask.
1178fn button_names(mask: u8) -> Vec<&'static str> {
1179    const NAMES: [&str; 5] = ["left", "middle", "right", "scroll_up", "scroll_down"];
1180    NAMES
1181        .iter()
1182        .enumerate()
1183        .filter(|(bit, _)| mask & (1 << bit) != 0)
1184        .map(|(_, name)| *name)
1185        .collect()
1186}
1187
1188/// Human-readable name for an X11 keysym, so the model never has to decode one.
1189fn keysym_name(keysym: u32) -> String {
1190    // Printable ASCII and Latin-1 keysyms are the character itself.
1191    if (0x20..=0x7E).contains(&keysym) || (0xA0..=0xFF).contains(&keysym) {
1192        if let Some(c) = char::from_u32(keysym) {
1193            return c.to_string();
1194        }
1195    }
1196    // Unicode keysyms: 0x01000000 + code point.
1197    if (0x01000100..=0x0110FFFF).contains(&keysym) {
1198        if let Some(c) = char::from_u32(keysym - 0x01000000) {
1199            return c.to_string();
1200        }
1201    }
1202    // Function keys F1-F35 are contiguous from 0xFFBE.
1203    if (0xFFBE..=0xFFE0).contains(&keysym) {
1204        return format!("F{}", keysym - 0xFFBD);
1205    }
1206    match keysym {
1207        0xFF08 => "BackSpace",
1208        0xFF09 => "Tab",
1209        0xFF0A => "Linefeed",
1210        0xFF0B => "Clear",
1211        0xFF0D => "Return",
1212        0xFF13 => "Pause",
1213        0xFF14 => "Scroll_Lock",
1214        0xFF15 => "Sys_Req",
1215        0xFF1B => "Escape",
1216        0xFF50 => "Home",
1217        0xFF51 => "Left",
1218        0xFF52 => "Up",
1219        0xFF53 => "Right",
1220        0xFF54 => "Down",
1221        0xFF55 => "Page_Up",
1222        0xFF56 => "Page_Down",
1223        0xFF57 => "End",
1224        0xFF58 => "Begin",
1225        0xFF63 => "Insert",
1226        0xFF7F => "Num_Lock",
1227        0xFF8D => "KP_Enter",
1228        0xFFE1 => "Shift_L",
1229        0xFFE2 => "Shift_R",
1230        0xFFE3 => "Control_L",
1231        0xFFE4 => "Control_R",
1232        0xFFE5 => "Caps_Lock",
1233        0xFFE7 => "Meta_L",
1234        0xFFE8 => "Meta_R",
1235        0xFFE9 => "Alt_L",
1236        0xFFEA => "Alt_R",
1237        0xFFEB => "Super_L",
1238        0xFFEC => "Super_R",
1239        0xFFFF => "Delete",
1240        _ => return format!("keysym_{keysym:#x}"),
1241    }
1242    .to_string()
1243}