Skip to main content

netget/client/spice/
actions.rs

1//! What the model can do as a SPICE client: type, press keys, move and click the mouse, and
2//! read the display back as colours and draw counts.
3use crate::llm::actions::{
4    client_trait::{Client, ClientActionResult},
5    protocol_trait::Protocol,
6    ActionDefinition, Parameter, ParameterDefinition, StartupExamples,
7};
8use crate::protocol::log_template::LogTemplate;
9use crate::protocol::{ConnectContext, EventType};
10use crate::server::spice::keys;
11use crate::state::app_state::AppState;
12use anyhow::{bail, ensure, Context, Result};
13use serde_json::{json, Value};
14use std::sync::LazyLock;
15
16/// Most characters one spice_type sends.
17pub const MAX_TEXT_CHARS: usize = 4096;
18/// Most key combinations one spice_keys sends.
19pub const MAX_KEYS: usize = 64;
20pub const DEFAULT_GRID: u64 = 4;
21pub const MAX_GRID: u64 = 16;
22/// Most pixels one spice_read_screen samples.
23pub const MAX_POINTS: usize = 32;
24pub const DEFAULT_SETTLE_MS: u64 = 500;
25/// The longest a read waits for the screen to settle.
26pub const MAX_SETTLE_WAIT_MS: u64 = 10_000;
27/// The largest coordinate an action may name.
28pub const MAX_COORD: u64 = 8192;
29
30#[derive(Default)]
31pub struct SpiceClientProtocol;
32
33impl SpiceClientProtocol {
34    pub fn new() -> Self {
35        Self
36    }
37}
38
39fn p(name: &str, type_hint: &str, description: &str, required: bool) -> Parameter {
40    Parameter {
41        name: name.into(),
42        type_hint: type_hint.into(),
43        description: description.into(),
44        required,
45    }
46}
47
48fn action(
49    name: &str,
50    description: &str,
51    parameters: Vec<Parameter>,
52    example: Value,
53) -> ActionDefinition {
54    ActionDefinition {
55        name: name.into(),
56        description: description.into(),
57        parameters,
58        example,
59        log_template: Some(
60            LogTemplate::new().with_info(format!("-> spice {}", name.trim_start_matches("spice_"))),
61        ),
62    }
63}
64
65fn type_action() -> ActionDefinition {
66    action(
67        "spice_type",
68        "Type text on the remote display as key presses (US keyboard layout; a newline presses Enter, a tab Tab). Characters a US keyboard cannot type are refused.",
69        vec![p("text", "string", "The text to type", true)],
70        json!({"type": "spice_type", "text": "hello world"}),
71    )
72}
73
74fn keys_action() -> ActionDefinition {
75    action(
76        "spice_keys",
77        "Press keys or key combinations in order, e.g. enter, esc, tab, ctrl+alt+delete, f12, up, a.",
78        vec![p("keys", "array", "Key names; join modifiers with + (ctrl, shift, alt, win)", true)],
79        json!({"type": "spice_keys", "keys": ["esc", "ctrl+alt+delete"]}),
80    )
81}
82
83fn mouse_action() -> ActionDefinition {
84    action(
85        "spice_mouse",
86        "Move the mouse to a point on the display and act there. In the server's client mouse mode the position is absolute; in server mode it is sent as relative motion from where this client last put the pointer.",
87        vec![
88            p("x", "number", "Pixels from the left edge", true),
89            p("y", "number", "Pixels from the top edge", true),
90            p(
91                "action",
92                "string",
93                "move, click (default), double_click, press, release, scroll_up or scroll_down",
94                false,
95            ),
96            p("button", "string", "left (default), right or middle", false),
97        ],
98        json!({"type": "spice_mouse", "x": 100, "y": 200, "action": "click", "button": "left"}),
99    )
100}
101
102fn read_screen_action() -> ActionDefinition {
103    action(
104        "spice_read_screen",
105        "Look at the display: once no draw has arrived for settle_ms, its size, most common colour, a grid of each cell's most common colour, a checksum, the colours at any points asked for, and counts of every draw command and image type the server sent arrive as spice_screen. exact is false when some draw could not be applied (a compressed image, a stream).",
106        vec![
107            p("grid", "number", "Cells per side of the colour grid (1-16, default 4)", false),
108            p("settle_ms", "number", "How long the display must go without draws first (default 500)", false),
109            p("points", "array", "Up to 32 [x, y] pixels whose colours to report", false),
110        ],
111        json!({"type": "spice_read_screen", "grid": 4}),
112    )
113}
114
115fn disconnect_action() -> ActionDefinition {
116    action(
117        "disconnect",
118        "Close every channel of the session.",
119        vec![],
120        json!({"type": "disconnect"}),
121    )
122}
123
124pub fn actions() -> Vec<ActionDefinition> {
125    vec![
126        type_action(),
127        keys_action(),
128        mouse_action(),
129        read_screen_action(),
130        disconnect_action(),
131    ]
132}
133
134fn ev(id: &str, description: &str, params: Vec<Parameter>) -> EventType {
135    EventType::new(id, description, read_screen_action().example.clone())
136        .with_parameters(params)
137        .with_actions(actions())
138}
139
140pub static CONNECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
141    ev(
142        "spice_connected",
143        "The SPICE session is up: the main channel linked and the display and inputs channels opened.",
144        vec![
145            p("server", "string", "The server's address", true),
146            p("session_id", "number", "The session id the server assigned", true),
147            p("channels", "array", "Channels the server offered, as type names", true),
148            p("mouse_mode", "string", "client (absolute) or server (relative)", true),
149            p("name", "string", "The guest name the server sent, if any", false),
150            p("agent_connected", "boolean", "Whether a guest agent is running", true),
151        ],
152    )
153});
154
155pub static SURFACE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
156    ev(
157        "spice_surface_created",
158        "The server created (or re-created) the primary display surface.",
159        vec![
160            p(
161                "width",
162                "number",
163                "Surface width in pixels; a spice_mouse x must be below it",
164                true,
165            ),
166            p("height", "number", "Surface height in pixels", true),
167            p(
168                "format",
169                "number",
170                "SPICE surface format (32 is 32-bit xRGB)",
171                true,
172            ),
173        ],
174    )
175});
176
177pub static SCREEN_EVENT: LazyLock<EventType> = LazyLock::new(|| {
178    ev(
179        "spice_screen",
180        "What the display looks like now, as colours, and what the server drew to get there.",
181        vec![
182            p("width", "number", "Width of the primary surface", true),
183            p("height", "number", "Height of the primary surface", true),
184            p(
185                "dominant_color",
186                "string",
187                "The most common colour, #rrggbb",
188                true,
189            ),
190            p(
191                "dominant_share",
192                "number",
193                "Fraction of the screen in that colour (0-1)",
194                true,
195            ),
196            p(
197                "grid",
198                "array",
199                "Rows top to bottom, each cell's most common colour",
200                true,
201            ),
202            p(
203                "checksum",
204                "string",
205                "FNV-1a of the surface's RGB bytes; it changes when the picture does",
206                true,
207            ),
208            p(
209                "points",
210                "array",
211                "The colours at the points asked for",
212                false,
213            ),
214            p(
215                "draw_ops",
216                "object",
217                "Count of each display message the server sent",
218                true,
219            ),
220            p(
221                "images",
222                "object",
223                "Count of each image type the server sent",
224                true,
225            ),
226            p(
227                "exact",
228                "boolean",
229                "False when some draw could not be applied to the picture",
230                true,
231            ),
232            p(
233                "settled",
234                "boolean",
235                "False when draws kept arriving past the wait",
236                true,
237            ),
238        ],
239    )
240});
241
242pub static NOTIFY_EVENT: LazyLock<EventType> = LazyLock::new(|| {
243    ev(
244        "spice_notify",
245        "The server sent a notification on the main channel.",
246        vec![
247            p("severity", "string", "info, warn or error", true),
248            p(
249                "message",
250                "string",
251                "The text, with control characters removed",
252                true,
253            ),
254        ],
255    )
256});
257
258pub static ERROR_EVENT: LazyLock<EventType> = LazyLock::new(|| {
259    ev(
260        "spice_error",
261        "An action could not be carried out.",
262        vec![
263            p("action", "string", "The action's type", true),
264            p("message", "string", "What went wrong", true),
265        ],
266    )
267});
268
269pub static DISCONNECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
270    ev(
271        "spice_disconnected",
272        "The session ended.",
273        vec![p(
274            "reason",
275            "string",
276            "Why the session ended: the server closed a channel, a bound was broken, or the client disconnected",
277            true,
278        )],
279    )
280});
281
282/// A validated action.
283#[derive(Debug, Clone, PartialEq)]
284pub enum Input {
285    /// (scancode, extended) presses and releases in order: (key, released).
286    Keys(Vec<((u8, bool), bool)>),
287    Mouse {
288        x: u32,
289        y: u32,
290        action: String,
291        button: String,
292    },
293    ReadScreen {
294        grid: u64,
295        settle_ms: u64,
296        points: Vec<(u32, u32)>,
297    },
298}
299
300const MOUSE_ACTIONS: [&str; 7] = [
301    "move",
302    "click",
303    "double_click",
304    "press",
305    "release",
306    "scroll_up",
307    "scroll_down",
308];
309
310const SHIFT: (u8, bool) = (0x2A, false);
311
312/// The key strokes that type `text`.
313pub fn strokes_for_text(text: &str) -> Result<Vec<((u8, bool), bool)>> {
314    let mut out = Vec::new();
315    for ch in text.chars() {
316        let (sc, shift) = keys::scancode_for_char(ch)
317            .with_context(|| format!("{ch:?} cannot be typed on a US keyboard"))?;
318        if shift {
319            out.push((SHIFT, false));
320        }
321        out.push(((sc, false), false));
322        out.push(((sc, false), true));
323        if shift {
324            out.push((SHIFT, true));
325        }
326    }
327    Ok(out)
328}
329
330/// The key strokes for one combination such as `ctrl+alt+delete`: pressed in order, released
331/// in reverse.
332pub fn strokes_for_combo(combo: &str) -> Result<Vec<((u8, bool), bool)>> {
333    let parts: Vec<&str> = if combo.trim() == "+" {
334        vec!["+"]
335    } else {
336        combo.split('+').map(str::trim).collect()
337    };
338    ensure!(
339        !parts.is_empty() && parts.iter().all(|p| !p.is_empty()),
340        "{combo:?} is not a key combination"
341    );
342    let mut keys_down = Vec::new();
343    for part in &parts {
344        let key = keys::named_key(part).with_context(|| format!("unknown key {part:?}"))?;
345        keys_down.push(key);
346    }
347    let mut out: Vec<((u8, bool), bool)> = keys_down.iter().map(|k| (*k, false)).collect();
348    out.extend(keys_down.iter().rev().map(|k| (*k, true)));
349    Ok(out)
350}
351
352pub fn check(v: &Value) -> Result<Input> {
353    match v["type"].as_str().unwrap_or_default() {
354        "spice_type" => {
355            let text = v["text"].as_str().context("text is required")?;
356            ensure!(
357                text.chars().count() <= MAX_TEXT_CHARS,
358                "text is at most {MAX_TEXT_CHARS} characters"
359            );
360            Ok(Input::Keys(strokes_for_text(text)?))
361        }
362        "spice_keys" => {
363            let list = v["keys"]
364                .as_array()
365                .context("keys is an array of key names")?;
366            ensure!(
367                !list.is_empty() && list.len() <= MAX_KEYS,
368                "keys holds 1 to {MAX_KEYS} entries"
369            );
370            let mut out = Vec::new();
371            for k in list {
372                out.extend(strokes_for_combo(
373                    k.as_str().context("each key is a string")?,
374                )?);
375            }
376            Ok(Input::Keys(out))
377        }
378        "spice_mouse" => {
379            let coord = |k: &str| -> Result<u32> {
380                let n = v[k]
381                    .as_u64()
382                    .with_context(|| format!("{k} is a number of pixels"))?;
383                ensure!(n < MAX_COORD, "{k} is off the display");
384                Ok(n as u32)
385            };
386            let action = v["action"].as_str().unwrap_or("click").to_string();
387            ensure!(
388                MOUSE_ACTIONS.contains(&action.as_str()),
389                "action is one of {}",
390                MOUSE_ACTIONS.join(", ")
391            );
392            let button = v["button"].as_str().unwrap_or("left").to_string();
393            ensure!(
394                ["left", "right", "middle"].contains(&button.as_str()),
395                "button is left, right or middle"
396            );
397            Ok(Input::Mouse {
398                x: coord("x")?,
399                y: coord("y")?,
400                action,
401                button,
402            })
403        }
404        "spice_read_screen" => {
405            let grid = v["grid"].as_u64().unwrap_or(DEFAULT_GRID);
406            ensure!((1..=MAX_GRID).contains(&grid), "grid is 1 to {MAX_GRID}");
407            let settle_ms = v["settle_ms"].as_u64().unwrap_or(DEFAULT_SETTLE_MS);
408            ensure!(
409                settle_ms <= MAX_SETTLE_WAIT_MS,
410                "settle_ms is at most {MAX_SETTLE_WAIT_MS}"
411            );
412            let mut points = Vec::new();
413            if let Some(list) = v.get("points").filter(|p| !p.is_null()) {
414                let list = list.as_array().context("points is an array of [x, y]")?;
415                ensure!(list.len() <= MAX_POINTS, "at most {MAX_POINTS} points");
416                for pt in list {
417                    let xy = pt
418                        .as_array()
419                        .filter(|a| a.len() == 2)
420                        .context("each point is [x, y]")?;
421                    let n = |i: usize| -> Result<u32> {
422                        let n = xy[i]
423                            .as_u64()
424                            .context("a point's coordinates are numbers")?;
425                        ensure!(n < MAX_COORD, "a point is off the display");
426                        Ok(n as u32)
427                    };
428                    points.push((n(0)?, n(1)?));
429                }
430            }
431            Ok(Input::ReadScreen {
432                grid,
433                settle_ms,
434                points,
435            })
436        }
437        other => bail!("unknown SPICE action {other:?}"),
438    }
439}
440
441impl Protocol for SpiceClientProtocol {
442    fn protocol_name(&self) -> &'static str {
443        "SPICE"
444    }
445    fn stack_name(&self) -> &'static str {
446        "ETH>IP>TCP>SPICE"
447    }
448    fn keywords(&self) -> Vec<&'static str> {
449        vec![
450            "spice",
451            "spice client",
452            "remote display",
453            "qemu",
454            "virt-viewer",
455            "remote-viewer",
456        ]
457    }
458    fn description(&self) -> &'static str {
459        "SPICE client: links main, display and inputs channels with a password, reads the display back as colours and draw counts, and types and clicks"
460    }
461    fn get_async_actions(&self, _: &AppState) -> Vec<ActionDefinition> {
462        actions()
463    }
464    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
465        vec![]
466    }
467    fn get_event_types(&self) -> Vec<EventType> {
468        vec![
469            CONNECTED_EVENT.clone(),
470            SURFACE_EVENT.clone(),
471            SCREEN_EVENT.clone(),
472            NOTIFY_EVENT.clone(),
473            ERROR_EVENT.clone(),
474            DISCONNECTED_EVENT.clone(),
475        ]
476    }
477    fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
478        vec![ParameterDefinition {
479            name: "password".into(),
480            type_hint: "string".into(),
481            description: "The server's SPICE password (at most 60 bytes), sent as the RSA-OAEP ticket every channel carries. Leave it out for a server without one".into(),
482            required: false,
483            example: json!("s3cret"),
484            default: None,
485        }]
486    }
487    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
488        use crate::protocol::metadata::*;
489        ProtocolMetadataV2::builder()
490            .state(DevelopmentState::Experimental)
491            .privilege_requirement(PrivilegeRequirement::None)
492            .implementation("Hand-written SPICE 2.2 client: link stage with the RSA-OAEP (SHA-1) ticket, mini and full headers, SET_ACK/ACK flow control and PING/PONG on every channel, main (INIT, CHANNELS_LIST, MOUSE_MODE, NAME, NOTIFY), display (primary surface, uncompressed and cached bitmaps through DRAW_COPY, solid DRAW_FILL, BLACKNESS/WHITENESS/INVERS, COPY_BITS, clip rectangles; asks for uncompressed images) and inputs (PC/AT scancodes, absolute or relative mouse)")
493            .llm_control("What to type, which keys to press, where to click, and when to look; the display comes back as size, colours, a checksum, sampled points and counts of what the server drew")
494            .e2e_testing("tests/client/spice: QEMU's spice-server (qemu-system-x86_64 -spice, no disk, SeaBIOS on the VGA console) — the surface size, the firmware screen appearing after the VM is resumed, keystrokes and mouse buttons read back from QEMU's input trace events, and a wrong password refused; tshark's spice dissector over both directions")
495            .notes("Experimental: one independent server (spice-server through QEMU). No TLS (tls-port), no SASL, no cursor, audio, agent, USB or smartcard channels. Compressed images (QUIC, LZ, GLZ, JPEG, LZ4) and video streams are counted but not decoded, so a reading says exact: false when they were drawn; the client asks for uncompressed images, which spice-server honours. One primary surface; off-screen surfaces are counted, not kept. Typing is US layout only; SPICE has no Unicode key event.")
496            .build()
497    }
498    fn example_prompt(&self) -> &'static str {
499        "Connect to the SPICE display at 127.0.0.1:5930 with password s3cret, press Escape and tell me what colour the screen is"
500    }
501    fn get_startup_examples(&self) -> StartupExamples {
502        let llm = json!({"type":"open_client","protocol":"SPICE","remote_addr":"127.0.0.1:5930",
503            "startup_params":{"password":"s3cret"},
504            "instruction":"Once connected, read the screen and report its main colour"});
505        let mut static_example = llm.clone();
506        static_example["event_handlers"] = json!([
507            {"event_pattern":"spice_connected","handler":{"type":"static","actions":[read_screen_action().example]}},
508            {"event_pattern":"*","handler":{"type":"static","actions":[]}}
509        ]);
510        let mut scripted = static_example.clone();
511        scripted["event_handlers"][0] = json!({"event_pattern":"spice_surface_created","handler":{"type":"script","language":"python",
512            "code":"import json,sys\ne=json.load(sys.stdin)['event']\nprint(json.dumps({'actions':[{'type':'spice_mouse','x':e['width']//2,'y':e['height']//2}]}))"}});
513        StartupExamples::new(llm, scripted, static_example)
514    }
515    fn group_name(&self) -> &'static str {
516        "Remote Access"
517    }
518}
519
520impl Client for SpiceClientProtocol {
521    fn connect(
522        &self,
523        ctx: ConnectContext,
524    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<std::net::SocketAddr>> + Send>>
525    {
526        Box::pin(super::connect(ctx))
527    }
528    fn execute_action(&self, v: Value) -> Result<ClientActionResult> {
529        if v["type"] == "disconnect" {
530            return Ok(ClientActionResult::Disconnect);
531        }
532        check(&v)?;
533        Ok(ClientActionResult::Custom {
534            name: v["type"].as_str().unwrap_or_default().to_string(),
535            data: v,
536        })
537    }
538}