1use crate::llm::actions::{
16 protocol_trait::{ActionResult, Protocol, Server},
17 ActionDefinition, Parameter, ParameterDefinition,
18};
19use crate::protocol::log_template::LogTemplate;
20use crate::protocol::EventType;
21use crate::server::vnc::actions::parse_display_commands;
22use crate::state::app_state::AppState;
23use anyhow::{anyhow, bail, Context, Result};
24use serde_json::{json, Value as JsonValue};
25use std::sync::LazyLock;
26
27pub const DEFAULT_WIDTH: u32 = 800;
28pub const DEFAULT_HEIGHT: u32 = 600;
29pub const MIN_SIDE: u32 = 16;
30pub const MAX_SIDE: u32 = 4096;
31pub const MAX_PIXELS: u64 = 3840 * 2160;
33pub const DEFAULT_NAME: &str = "NetGet SPICE";
34pub const MAX_NOTIFY_LEN: usize = 1024;
36
37pub const RESULT_ACCEPT: &str = "spice_accept";
39pub const RESULT_REJECT: &str = "spice_reject";
40pub const RESULT_DISPLAY: &str = "spice_display";
41pub const RESULT_NO_CHANGE: &str = "spice_no_change";
42pub const RESULT_NOTIFY: &str = "spice_notify";
43
44#[derive(Clone, Default)]
45pub struct SpiceProtocol;
46
47impl SpiceProtocol {
48 pub fn new() -> Self {
49 Self
50 }
51}
52
53fn p(name: &str, type_hint: &str, description: &str, required: bool) -> Parameter {
54 Parameter {
55 name: name.into(),
56 type_hint: type_hint.into(),
57 description: description.into(),
58 required,
59 }
60}
61
62fn accept_action() -> ActionDefinition {
63 ActionDefinition {
64 name: "spice_accept_connection".into(),
65 description: "Admit this SPICE client: the link completes and its main channel opens. \
66 Without this the client is refused (SPICE_LINK_ERR_PERMISSION_DENIED)."
67 .into(),
68 parameters: vec![],
69 example: json!({"type": "spice_accept_connection"}),
70 log_template: Some(LogTemplate::new().with_info("SPICE client admitted")),
71 }
72}
73
74fn reject_action() -> ActionDefinition {
75 ActionDefinition {
76 name: "spice_reject_connection".into(),
77 description: "Refuse this SPICE client: the link ends with \
78 SPICE_LINK_ERR_PERMISSION_DENIED, which the client reports as an \
79 authentication failure."
80 .into(),
81 parameters: vec![],
82 example: json!({"type": "spice_reject_connection"}),
83 log_template: Some(LogTemplate::new().with_info("SPICE client refused")),
84 }
85}
86
87fn render_action() -> ActionDefinition {
88 ActionDefinition {
89 name: "spice_render_display".into(),
90 description:
91 "Draw the whole screen and send it to the client. Describe it with drawing commands, \
92 never pixels. Each command is an object with a \"type\": \"background\" {color}, \
93 \"clear\", \"rectangle\" {x,y,width,height,color,filled}, \"text\" \
94 {x,y,text,size,color}, \"line\" {x1,y1,x2,y2,color,width}, \"circle\" \
95 {x,y,radius,color,filled}, \"window\" {x,y,width,height,title,content:[...]}, \
96 \"button\" {x,y,width,height,label}, \"textbox\" {x,y,width,height,text,placeholder}, \
97 \"ascii_art\" {text,size,color,background}. Commands are drawn in order, so start \
98 with a \"background\". Colours are hex (\"#1e293b\"), names (\"blue\") or \
99 {\"r\":30,\"g\":41,\"b\":59}. Coordinates are pixels from the top left"
100 .into(),
101 parameters: vec![p(
102 "commands",
103 "array",
104 "Ordered drawing commands making up the entire screen; it is redrawn from scratch",
105 true,
106 )],
107 example: json!({
108 "type": "spice_render_display",
109 "commands": [
110 {"type": "background", "color": "#1e293b"},
111 {"type": "text", "x": 40, "y": 60, "text": "Hello from NetGet", "size": 28, "color": "white"},
112 {"type": "rectangle", "x": 40, "y": 100, "width": 240, "height": 80, "color": "#38bdf8", "filled": true}
113 ]
114 }),
115 log_template: Some(
116 LogTemplate::new()
117 .with_info("SPICE screen redrawn")
118 .with_trace("SPICE render: {json_pretty(.)}"),
119 ),
120 }
121}
122
123fn no_change_action() -> ActionDefinition {
124 ActionDefinition {
125 name: "spice_no_change".into(),
126 description: "Leave the screen as it is: nothing is sent. Use it for input the screen \
127 does not react to."
128 .into(),
129 parameters: vec![],
130 example: json!({"type": "spice_no_change"}),
131 log_template: Some(LogTemplate::new().with_debug("SPICE: screen unchanged")),
132 }
133}
134
135fn notify_action() -> ActionDefinition {
136 ActionDefinition {
137 name: "spice_notify".into(),
138 description: "Send a notification on the main channel (SPICE_MSG_NOTIFY). Viewers log \
139 it; they do not draw it, so put anything the user must see on the screen."
140 .into(),
141 parameters: vec![
142 p(
143 "message",
144 "string",
145 &format!("The notification text, at most {MAX_NOTIFY_LEN} bytes"),
146 true,
147 ),
148 p("severity", "string", "info (default), warn or error", false),
149 ],
150 example: json!({"type": "spice_notify", "message": "Welcome", "severity": "info"}),
151 log_template: Some(
152 LogTemplate::new()
153 .with_info("SPICE notification sent")
154 .with_debug("SPICE notify: {preview(message,200)}"),
155 ),
156 }
157}
158
159fn disconnect_action() -> ActionDefinition {
160 ActionDefinition {
161 name: "spice_disconnect_client".into(),
162 description: "End this client's whole SPICE session: every channel is closed.".into(),
163 parameters: vec![],
164 example: json!({"type": "spice_disconnect_client"}),
165 log_template: Some(LogTemplate::new().with_info("SPICE session closed by decision")),
166 }
167}
168
169pub static ACCEPT_ACTION: LazyLock<ActionDefinition> = LazyLock::new(accept_action);
170pub static REJECT_ACTION: LazyLock<ActionDefinition> = LazyLock::new(reject_action);
171pub static RENDER_ACTION: LazyLock<ActionDefinition> = LazyLock::new(render_action);
172pub static NO_CHANGE_ACTION: LazyLock<ActionDefinition> = LazyLock::new(no_change_action);
173pub static NOTIFY_ACTION: LazyLock<ActionDefinition> = LazyLock::new(notify_action);
174pub static DISCONNECT_ACTION: LazyLock<ActionDefinition> = LazyLock::new(disconnect_action);
175
176fn screen_actions() -> Vec<ActionDefinition> {
177 vec![
178 RENDER_ACTION.clone(),
179 NO_CHANGE_ACTION.clone(),
180 NOTIFY_ACTION.clone(),
181 DISCONNECT_ACTION.clone(),
182 ]
183}
184
185pub static CONNECTION_REQUEST_EVENT: LazyLock<EventType> = LazyLock::new(|| {
186 EventType::new(
187 "spice_connection_request",
188 "A SPICE client linked its main channel. Admit it (spice_accept_connection, optionally \
189 with a spice_render_display for its first screen) or refuse it",
190 json!({"type": "spice_accept_connection"}),
191 )
192 .with_parameters(vec![
193 p("client", "string", "The client's address", true),
194 p(
195 "password_configured",
196 "boolean",
197 "True when the server requires a password; the client's ticket has then already \
198 been checked against it, and only a correct one reaches you",
199 true,
200 ),
201 p(
202 "width",
203 "number",
204 "Width in pixels of the screen this client will see",
205 true,
206 ),
207 p("height", "number", "Its height in pixels", true),
208 p(
209 "client_capabilities",
210 "array",
211 "Common capabilities the client offered (auth_selection, auth_spice, auth_sasl, \
212 mini_header)",
213 true,
214 ),
215 ])
216 .with_actions(vec![
217 ACCEPT_ACTION.clone(),
218 REJECT_ACTION.clone(),
219 RENDER_ACTION.clone(),
220 NOTIFY_ACTION.clone(),
221 ])
222 .with_alternative_example(json!({"type": "spice_reject_connection"}))
223 .with_log_template(
224 LogTemplate::new()
225 .with_info("SPICE client {client} asks to connect")
226 .with_trace("SPICE connection request: {json_pretty(.)}"),
227 )
228});
229
230pub static DISPLAY_READY_EVENT: LazyLock<EventType> =
231 LazyLock::new(|| {
232 EventType::new(
233 "spice_display_ready",
234 "A SPICE client opened its display channel and nothing has been drawn yet: draw the \
235 whole screen",
236 json!({
237 "type": "spice_render_display",
238 "commands": [
239 {"type": "background", "color": "#1e293b"},
240 {"type": "text", "x": 40, "y": 60, "text": "NetGet", "size": 32, "color": "white"}
241 ]
242 }),
243 )
244 .with_parameters(vec![
245 p("width", "number", "Surface width in pixels - draw within this", true),
246 p("height", "number", "Surface height in pixels", true),
247 ])
248 .with_actions(screen_actions())
249 .with_log_template(
250 LogTemplate::new()
251 .with_info("SPICE display ready ({width}x{height})")
252 .with_trace("SPICE display ready: {json_pretty(.)}"),
253 )
254 });
255
256pub static KEY_EVENT: LazyLock<EventType> = LazyLock::new(|| {
257 EventType::new(
258 "spice_key_event",
259 "A key was pressed on the SPICE client's keyboard - redraw the screen if it should react",
260 json!({
261 "type": "spice_render_display",
262 "commands": [
263 {"type": "background", "color": "#1e293b"},
264 {"type": "text", "x": 40, "y": 60, "text": "You typed: a", "size": 24, "color": "white"}
265 ]
266 }),
267 )
268 .with_parameters(vec![
269 p(
270 "key",
271 "string",
272 "The key: its character for a printable key (shift applied), otherwise a name such \
273 as Return, BackSpace, Tab, Escape, Left, F1",
274 true,
275 ),
276 p(
277 "text",
278 "string",
279 "The character the key types (US layout), or null for a key that types nothing",
280 false,
281 ),
282 p("scancode", "number", "PC/AT set-1 scancode", true),
283 p("extended", "boolean", "True for an 0xE0-prefixed key", true),
284 p("shift", "boolean", "True when a Shift key was held as this key went down", true),
285 p("ctrl", "boolean", "True when a Control key was held as this key went down", true),
286 p("alt", "boolean", "True when an Alt key was held as this key went down", true),
287 ])
288 .with_actions(screen_actions())
289 .with_alternative_example(json!({"type": "spice_no_change"}))
290 .with_log_template(
291 LogTemplate::new()
292 .with_info("SPICE key {key}")
293 .with_debug("SPICE key: {json(.)}"),
294 )
295});
296
297pub static MOUSE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
298 EventType::new(
299 "spice_mouse_event",
300 "A mouse button went down on the SPICE client - redraw the screen if it should react",
301 json!({
302 "type": "spice_render_display",
303 "commands": [
304 {"type": "background", "color": "#1e293b"},
305 {"type": "circle", "x": 100, "y": 100, "radius": 20, "color": "#f97316", "filled": true}
306 ]
307 }),
308 )
309 .with_parameters(vec![
310 p("x", "number", "Pointer X in surface pixels", true),
311 p("y", "number", "Pointer Y in surface pixels", true),
312 p(
313 "button",
314 "string",
315 "left, middle, right, scroll_up or scroll_down",
316 true,
317 ),
318 p(
319 "buttons",
320 "array",
321 "Buttons held after this press: left, middle, right",
322 true,
323 ),
324 p(
325 "mouse_mode",
326 "string",
327 "client (absolute positions) or server (relative motion, so x and y are where \
328 NetGet has tracked the pointer to)",
329 true,
330 ),
331 ])
332 .with_actions(screen_actions())
333 .with_alternative_example(json!({"type": "spice_no_change"}))
334 .with_log_template(
335 LogTemplate::new()
336 .with_info("SPICE {button} button at {x},{y}")
337 .with_debug("SPICE mouse: {json(.)}"),
338 )
339});
340
341pub fn event_types() -> Vec<EventType> {
342 vec![
343 CONNECTION_REQUEST_EVENT.clone(),
344 DISPLAY_READY_EVENT.clone(),
345 KEY_EVENT.clone(),
346 MOUSE_EVENT.clone(),
347 ]
348}
349
350pub fn validate_size(width: u32, height: u32) -> Result<(u32, u32)> {
352 for (name, v) in [("width", width), ("height", height)] {
353 if !(MIN_SIDE..=MAX_SIDE).contains(&v) {
354 bail!("{name} must be between {MIN_SIDE} and {MAX_SIDE}, got {v}");
355 }
356 }
357 if u64::from(width) * u64::from(height) > MAX_PIXELS {
358 bail!("{width}x{height} is above the {MAX_PIXELS} pixel limit");
359 }
360 Ok((width, height))
361}
362
363pub fn parse_severity(v: Option<&str>) -> Result<u32> {
364 use crate::server::spice::wire::*;
365 Ok(match v.unwrap_or("info") {
366 "info" => NOTIFY_SEVERITY_INFO,
367 "warn" | "warning" => NOTIFY_SEVERITY_WARN,
368 "error" => NOTIFY_SEVERITY_ERROR,
369 other => bail!("severity is info, warn or error, not {other:?}"),
370 })
371}
372
373impl Protocol for SpiceProtocol {
374 fn protocol_name(&self) -> &'static str {
375 "SPICE"
376 }
377 fn stack_name(&self) -> &'static str {
378 "ETH>IP>TCP>SPICE"
379 }
380 fn description(&self) -> &'static str {
381 "SPICE remote display server (main, display and inputs channels) whose screen the LLM draws and whose admission the LLM decides"
382 }
383 fn example_prompt(&self) -> &'static str {
384 "Start a SPICE server on port 5930 with password 'pw' showing a login banner, and greet each key the user types"
385 }
386 fn keywords(&self) -> Vec<&'static str> {
387 vec![
388 "spice",
389 "spice server",
390 "remote display",
391 "qxl",
392 "virt-viewer",
393 "remote-viewer",
394 ]
395 }
396 fn group_name(&self) -> &'static str {
397 "Remote Access"
398 }
399
400 fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
401 use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};
402 ProtocolMetadataV2::builder()
403 .state(DevelopmentState::Experimental)
404 .answers_on_failure()
405 .max_inbound_bytes(crate::server::spice::MAX_CLIENT_MESSAGE_BYTES as usize)
406 .implementation(
407 "Hand-written SPICE 2.2 (no crate): the link stage with an RSA-1024 key per server \
408 and the RSA-OAEP (SHA-1) ticket checked in Rust, mini and full data headers, a \
409 main channel (INIT, NAME, CHANNELS_LIST, MOUSE_MODE, NOTIFY), a display channel \
410 with one primary 32-bit xRGB surface drawn as uncompressed top-down bitmaps \
411 (DRAW_COPY), and an inputs channel decoding PC/AT scancodes and mouse \
412 position, motion and buttons. tiny-skia + cosmic-text render the model's \
413 drawing commands",
414 )
415 .llm_control(
416 "Admission (spice_connection_request, after Rust has checked the password), the \
417 screen (spice_render_display on admission, spice_display_ready, and in reply to \
418 spice_key_event and spice_mouse_event), notifications and disconnects",
419 )
420 .e2e_testing(
421 "tests/server/spice: spice-gtk's spicy-screenshot reads back the rendered \
422 surface pixel by pixel and refuses a wrong password; spicy under Xvfb with \
423 xdotool typing into it delivers keys to the model; spice-html5 in headless \
424 Chrome through websockify renders the same screen; tshark's spice dissector \
425 reads both directions",
426 )
427 .notes(
428 "Experimental: two independent clients drive it (spice-gtk and spice-html5) but \
429 nobody has reviewed it against the spice-protocol headers line by line, no \
430 fuzz target exists, and its pair job had not run in CI when it landed. No TLS channel (NEED_SECURED is never sent; tls-port is not \
431 offered), no SASL, no cursor, audio, agent, USB or smartcard channels, no image \
432 compression (every update is width*height*4 bytes), no streams, one display \
433 and one surface per session. The screen is redrawn whole on every change. \
434 Without a password any ticket is accepted and admission is the model's alone. \
435 Key releases and pointer movement are logged, not sent to the model.",
436 )
437 .build()
438 }
439
440 fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
441 vec![
442 ParameterDefinition {
443 name: "password".into(),
444 type_hint: "string".into(),
445 description: format!(
446 "Password every channel's ticket must carry (at most \
447 {} bytes). Checked in Rust; never shown to the model. Without one any \
448 ticket is accepted, as QEMU's disable-ticketing, and admission is the \
449 model's decision alone",
450 crate::server::spice::wire::MAX_PASSWORD_LENGTH
451 ),
452 required: false,
453 example: json!("s3cret"),
454 default: None,
455 },
456 ParameterDefinition {
457 name: "width".into(),
458 type_hint: "number".into(),
459 description: format!("Surface width in pixels ({MIN_SIDE}-{MAX_SIDE})"),
460 required: false,
461 example: json!(1024),
462 default: Some(json!(DEFAULT_WIDTH)),
463 },
464 ParameterDefinition {
465 name: "height".into(),
466 type_hint: "number".into(),
467 description: format!("Surface height in pixels ({MIN_SIDE}-{MAX_SIDE})"),
468 required: false,
469 example: json!(768),
470 default: Some(json!(DEFAULT_HEIGHT)),
471 },
472 ParameterDefinition {
473 name: "name".into(),
474 type_hint: "string".into(),
475 description: "Guest name sent in SPICE_MSG_MAIN_NAME; viewers put it in their \
476 title bar"
477 .into(),
478 required: false,
479 example: json!("NetGet Desktop"),
480 default: Some(json!(DEFAULT_NAME)),
481 },
482 ]
483 }
484
485 fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
486 Vec::new()
487 }
488
489 fn get_sync_actions(&self) -> Vec<ActionDefinition> {
490 vec![
491 accept_action(),
492 reject_action(),
493 render_action(),
494 no_change_action(),
495 notify_action(),
496 disconnect_action(),
497 ]
498 }
499
500 fn get_event_types(&self) -> Vec<EventType> {
501 event_types()
502 }
503
504 fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
505 use crate::llm::actions::StartupExamples;
506 StartupExamples::new(
507 json!({
508 "type": "open_server",
509 "port": 5930,
510 "base_stack": "spice",
511 "startup_params": {"password": "s3cret", "width": 1024, "height": 768},
512 "instruction": "Admit every client. Show a dark blue desktop with a window \
513 titled 'Terminal'; append each typed character to it."
514 }),
515 json!({
516 "type": "open_server",
517 "port": 5930,
518 "base_stack": "spice",
519 "event_handlers": [{
520 "event_pattern": "*",
521 "handler": {
522 "type": "script",
523 "language": "python",
524 "code": "import json,sys\ni=json.load(sys.stdin); t=i['event_type_id']; e=i['event']\nscreen=lambda s:{'type':'spice_render_display','commands':[{'type':'background','color':'#1e293b'},{'type':'text','x':40,'y':60,'text':s,'size':28,'color':'white'}]}\na=[]\nif t=='spice_connection_request': a=[{'type':'spice_accept_connection'},screen('Welcome')]\nelif t=='spice_key_event': a=[screen('You typed '+e['key'])]\nelse: a=[screen('NetGet')]\nprint(json.dumps({'actions':a}))"
525 }
526 }]
527 }),
528 json!({
529 "type": "open_server",
530 "port": 5930,
531 "base_stack": "spice",
532 "event_handlers": [
533 {"event_pattern": "spice_connection_request", "handler": {"type": "static", "actions": [
534 {"type": "spice_accept_connection"},
535 {"type": "spice_render_display", "commands": [
536 {"type": "background", "color": "#1e293b"},
537 {"type": "text", "x": 40, "y": 60, "text": "NetGet SPICE", "size": 28, "color": "white"}
538 ]}
539 ]}},
540 {"event_pattern": "*", "handler": {"type": "static", "actions": [{"type": "spice_no_change"}]}}
541 ]
542 }),
543 )
544 }
545}
546
547impl Server for SpiceProtocol {
548 fn spawn(
549 &self,
550 ctx: crate::protocol::SpawnContext,
551 ) -> std::pin::Pin<
552 Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
553 > {
554 Box::pin(async move {
555 let params = ctx.startup_params.as_ref();
556 let width = params
557 .map(|p| p.get_optional_u32("width"))
558 .transpose()?
559 .flatten()
560 .unwrap_or(DEFAULT_WIDTH);
561 let height = params
562 .map(|p| p.get_optional_u32("height"))
563 .transpose()?
564 .flatten()
565 .unwrap_or(DEFAULT_HEIGHT);
566 let (width, height) = validate_size(width, height)?;
567 let password = params
568 .map(|p| p.get_optional_string("password"))
569 .transpose()?
570 .flatten();
571 if let Some(pw) = &password {
572 if pw.len() > crate::server::spice::wire::MAX_PASSWORD_LENGTH {
573 bail!(
574 "password is {} bytes; SPICE tickets carry at most {}",
575 pw.len(),
576 crate::server::spice::wire::MAX_PASSWORD_LENGTH
577 );
578 }
579 }
580 let name = params
581 .map(|p| p.get_optional_string("name"))
582 .transpose()?
583 .flatten()
584 .unwrap_or_else(|| DEFAULT_NAME.to_string());
585 crate::server::spice::SpiceServer::spawn(
586 ctx.legacy_listen_addr(),
587 ctx.llm_client,
588 ctx.state,
589 ctx.status_tx,
590 ctx.server_id,
591 crate::server::spice::Config {
592 width,
593 height,
594 password,
595 name,
596 },
597 )
598 .await
599 })
600 }
601
602 fn execute_action(&self, action: JsonValue) -> Result<ActionResult> {
603 let kind = action
604 .get("type")
605 .and_then(|v| v.as_str())
606 .ok_or_else(|| anyhow!("Missing 'type' field in action"))?;
607 let custom = |name: &str, data: JsonValue| ActionResult::Custom {
608 name: name.to_string(),
609 data,
610 };
611 match kind {
612 "spice_accept_connection" => Ok(custom(RESULT_ACCEPT, json!({}))),
613 "spice_reject_connection" => Ok(custom(RESULT_REJECT, json!({}))),
614 "spice_render_display" => {
615 let commands = action.get("commands").ok_or_else(|| {
616 anyhow!(
617 "spice_render_display requires a 'commands' array describing the screen"
618 )
619 })?;
620 let parsed = parse_display_commands(commands)
621 .context("invalid 'commands' in spice_render_display")?;
622 Ok(custom(
623 RESULT_DISPLAY,
624 json!({"commands": serde_json::to_value(parsed)?}),
625 ))
626 }
627 "spice_no_change" => Ok(custom(RESULT_NO_CHANGE, json!({}))),
628 "spice_notify" => {
629 let message = action
630 .get("message")
631 .and_then(|v| v.as_str())
632 .ok_or_else(|| anyhow!("spice_notify requires a 'message' string"))?;
633 if message.len() > MAX_NOTIFY_LEN {
634 bail!(
635 "spice_notify message is {} bytes, above {MAX_NOTIFY_LEN}",
636 message.len()
637 );
638 }
639 let severity = parse_severity(action.get("severity").and_then(|v| v.as_str()))?;
640 Ok(custom(
641 RESULT_NOTIFY,
642 json!({"message": message, "severity": severity}),
643 ))
644 }
645 "spice_disconnect_client" | "close_connection" => Ok(ActionResult::CloseConnection),
646 other => Err(anyhow!(
647 "Unknown SPICE action '{other}'. Valid actions: spice_accept_connection, \
648 spice_reject_connection, spice_render_display, spice_no_change, spice_notify, \
649 spice_disconnect_client"
650 )),
651 }
652 }
653}