Skip to content

spice

Recipe card from the charly-check plugin (Commands — runtime CLI verbs).

spice: is a DECLARATIVE check verb — authored as spice: <method> inside a candy/box plan check:/run: step. There is no host charly check spice command. The SPICE-wire implementation (and the upstream SPICE client library + its cgo opus/portaudio audio transitives) was dep-shed into the out-of-tree candy/plugin-spice plugin module; at check time the host dispatches spice: through the provider registry to that out-of-process plugin (the same path a bed’s checks take via charly check live / charly check run). This mirrors the adb: / appium: / kube: externalizations (see charly/check_cmd.go).

The verb proves the SPICE server is speaking the protocol correctly on the wire — not just that the TCP port is open: main-channel handshake, auth, channel enumeration, native display-channel image decode, and input injection. VM-only — it needs a running libvirt VM that exposes a <graphics type='spice'> device.

The plugin resolves its endpoint via a reverse-leg; then speaks the wire

Section titled “The plugin resolves its endpoint via a reverse-leg; then speaks the wire”

charly core owns NO go-libvirt. The out-of-process spice plugin resolves its own dialable endpoint through the GENERIC cc.ResolveGraphicsEndpoint("spice") reverse-leg; the host side of that leg (resolveVerbGraphics in charly/check_graphics_endpoint.go — a permanent core STAY on its own x/crypto/ssh containment reason, a DIFFERENT file from the endpoint-resolve reverse-leg charly/check_endpoint_resolve.go, whose resolution bodies relocated to candy/plugin-check under the compiled-in-REQUIRED placement class, #55 W3 B7) DELEGATES the vm.yml → libvirt-domain → live-XML → <graphics type='spice'> resolution to the out-of-process vm plugin (invokeVmPlugin("resolve-spice", …) → candy/plugin-vm’s ResolveVmTarget / SpiceEndpoint, where the go-libvirt deps live), passing the resolved per-deploy DOMAIN IDENTITY (Runner.vmTargetName() — the deploy name, not the shared kind:vm entity, P33) as the domain target. The host opens any qemu+ssh:// side tunnel itself (tracked for post-Invoke teardown) and returns a plain DIALABLE endpoint (+ the SPICE ticket); the spice plugin just dials it and runs the method.

The verb is one inline Op carried by a step — an ordered list item under the candy/box plan: (a display/handshake probe is a check: step; an input action that changes guest state is a run: step). The method name is the scalar value for a bare-method step (spice: status), or the method: key of the spice: map when the step carries spice-exclusive fields — those live INSIDE the spice: map:

Method Declarative form Map fields Description
status spice: status handshake + channel enumeration (first line SPICE: ok)
screenshot spice: {method: screenshot, artifact: …} artifact: native SPICE display-channel decode → PNG
cursor spice: {method: cursor, artifact: …} artifact: capture cursor bitmap + position → PNG
record spice: {method: record, action: start|stop, fps?} + record_name?/artifact:` on stop artifact: + validators HOST-SIDE MJPEG video capture of the VM display: start polls the SPICE display framebuffer at fps (default 5), encoding every poll as a JPEG frame into a single MJPEG stream; stop flushes it to the host artifact (pure-Go; limits: server video streams/GLZ are not decoded by the vendored client, audio stubbed)
click spice: {method: click, x: …, y: …} x:, y:, button: mouse press/release via the inputs channel
mouse spice: {method: mouse, x: …, y: …} x:, y: pointer move (no click)
type spice: {method: type, text: …} text: type text as PC-AT scancodes
key spice: {method: key, key: …} key: press one named key (Return, Escape, F2, …)

The artifact validators (artifact_min_bytes:, artifact_min_dimensions:, artifact_not_uniform:) are spice-exclusive too, so they live inside the spice: map; only the shared matchers (stdout:, stderr:, exit_status:) and context:/timeout:/id: stay siblings of the spice: key. context: [deploy] — the verb needs a running VM; under charly check box (no running VM) it skips, and a SPICE-less deployment (e.g. a GPU desktop with no <graphics type='spice'> device) is reported N/A SKIP.

The method-name enum (status/screenshot/cursor/click/mouse/type/key) and every spice modifier live in the plugin’s OWN input schema (candy/plugin-spice/schema/spice.cue, #SpiceInput), served over the Describe channel and spliced onto the base for validation — so authoring is UNCHANGED from a built-in verb (spice: status, not plugin: spice); the internal plugin/plugin_input wire envelope the sugar desugars to is never authored.

Example — each step is an ordered list item under the candy/box plan::

- check: the SPICE server completes the handshake and enumerates channels
id: spice-handshake
spice: status
context: [deploy]
stdout:
- contains: ok
- contains: "inputs: ready"
- check: the SPICE display channel decodes a non-uniform framebuffer
id: desktop-rendered
spice:
method: screenshot
artifact: /tmp/spice-shot.png
artifact_not_uniform: true
context: [deploy]

Input injection is authored as run: steps (each one mutates guest state) — a console login sequence walks the form with spice: key / spice: type steps:

- run: type the username and password at the console
id: drive-login
spice:
method: key
key: return
context: [deploy]
# … followed by `spice: {method: type, text: arch}` and `spice: {method: key, key: tab}`
# steps to walk the login form.
### Capturing the display as video (`record`)
```yaml
- check: SPICE video recording starts on the VM display
id: spice-rec-start
context: [deploy]
spice:
method: record
action: start
record_name: vm-walk
fps: 5
- check: a desktop-visible action runs during the capture
id: spice-rec-drive
context: [deploy]
command: "notify-send 'demo' 'capturing' 2>/dev/null || true; sleep 3"
- check: the recording stops and a real MJPEG stream lands on the HOST
id: spice-rec-stop
context: [deploy]
spice:
method: record
action: stop
record_name: vm-walk
artifact: /tmp/demo.mjpeg
artifact_min_bytes: 20000

The artifact is pulled host-side at stop BEFORE the disposable bed’s teardown destroys the venue (copy-before-teardown); reality-check with ffprobe (frame count) and a frame extract (ffmpeg -i <file>.mjpeg -frames:v 1 out.png).

## Remote libvirt (qemu+ssh://)
A `spice:` step can target a VM on a remote libvirt host. Set
`CHARLY_LIBVIRT_URI=qemu+ssh://[user@]host/session` (the former `--uri` flag
carried this same env): the host-side pre-resolver runs locally, discovers the
remote SPICE endpoint, and opens the side tunnel transparently — libvirt RPC
rides the SSH control channel; the SPICE display channel gets a dedicated
forward.
- For VMs that declare `<listen type='socket'/>` (the arch default after the
socket-listen cutover), the host forwards the UNIX socket.
- For TCP-listener VMs, the host opens a `127.0.0.1:<random>` forward.
GUI clients (virt-manager, `remote-viewer --connect qemu+ssh://…`) don't need
any charly involvement for socket listeners — they auto-forward via libvirt
RPC fd-passing. See [`/charly-vm:arch-cloud-vm`](/recipes/vm/arch-cloud-vm/) "Connecting from a remote workstation"
for the complete story.
## What it does (and doesn't)
- **Speaks SPICE on the wire.** Main channel handshake, auth (None or
SPICE_TICKET), channel enumeration (Display/Inputs/Cursor/Playback/
Record/Webdav), display channel with native QUIC/GLZ/LZ/LZ4 image
decode, input channel for key/mouse events.
- **Endpoint resolved host-side.** The host loads vm.yml, finds the running
libvirt domain, parses live XML via `libvirtxml.Domain`, and extracts the
SPICE host/port/passwd from the `<graphics type='spice'>` element (honoring
autoport='yes') before handing the plugin a dialable endpoint. The operator
escape-hatches the former CLI exposed (`--address`, `--socket`, `--password`)
are NOT part of the declarative verb — the endpoint comes from the bed's VM
context.
- **Native SPICE screenshot.** `spice: screenshot` is a native SPICE
display-channel decode (NOT libvirt `DomainScreenshot`) — it proves the SPICE
display path renders pixels end-to-end. `type`/`key` emit PC-AT scancodes
(the friendly-keyname → scancode table lives in the plugin).
- **Not implemented.** Audio playback/record (no user story) — the plugin is
built WITHOUT `-tags spice_audio`, so the upstream library's cgo
opus/portaudio audio channels are not linked. Complex agent-channel
operations (clipboard, resolution change); the upstream library exposes them
but the verb doesn't wrap them yet — use the `libvirt:` verb (`libvirt:
passwd`, `libvirt: guest/exec`) for the corresponding management ops.
## Architecture split (vs. the `libvirt:` verb)
The verbs are single-protocol by design:
- **`spice:`** — every byte flows through the SPICE wire. Use when the thing
under test is "is SPICE itself healthy?".
- **`libvirt:`** — every call goes through libvirtd RPC. Use when the thing
under test is "is the VM working?" (framebuffer capture, keyboard injection,
snapshots, domain state). The `libvirt:` verb is likewise a declarative check
verb served out-of-process — by `candy/plugin-vm` (see [`/charly-check:libvirt`](/recipes/check/libvirt/)).
For input testing, prefer `spice: type`/`key`/`click` to prove the SPICE wire
delivers input to the guest. For display testing, compare `spice: screenshot`
against `libvirt: screenshot` — if both render the same pixels, the SPICE
server + the guest framebuffer agree.
## Implementation
The `spice:` verb and its SPICE-wire client live in the out-of-tree
`candy/plugin-spice` plugin module (an external-charly-verb plugin), NOT in
charly's core (which carries no SPICE library and no opus/portaudio cgo deps).
- `candy/plugin-spice/provider.go` — the out-of-process verb provider: it
dials the host-pre-resolved endpoint, dispatches the method, then
self-evaluates the stdout/stderr/exit_status matchers + the artifact
validators itself.
- `candy/plugin-spice/session.go` / `methods.go` — the connection wrapper over
the SPICE library's `Connector`/`Driver` interfaces and the per-method
implementations.
- `candy/plugin-spice/third_party/spice` — the vendored Shells-com/spice
library, built without the audio tag (`ch-audio-stub.go`).
- `candy/plugin-spice/schema/spice.cue` — the plugin's served CUE schema: the
`#SpiceInput` def carries the method enum + every spice modifier, served over
the Describe channel and spliced onto the base for authored-input validation.
Host side:
- `charly/check_graphics_endpoint.go` — `resolveVerbGraphics("spice")`, the host side
of the `cc.ResolveGraphicsEndpoint` reverse-leg: delegates vm.yml → libvirt domain →
live XML → SPICE endpoint to the vm plugin, opens any qemu+ssh:// side tunnel, and
returns a dialable endpoint (+ ticket) to the spice plugin. Stays in core (it owns no
go-libvirt, and directly imports spec/sshx — x/crypto/ssh containment); shared with
`vnc:` (the venue-aware vnc/spice resolution is one function). NOT
`charly/check_endpoint_resolve.go` — that is the DIFFERENT, sibling reverse-leg file
whose resolution bodies relocated to `candy/plugin-check/resolve_endpoint.go` (#55 W3 B7).
- `candy/plugin-vm/vm_target.go` — the OUT-OF-PROCESS VM target resolution
(`ResolveVmTarget` / `SpiceEndpoint`, go-libvirt); `VmTarget.XML` gives the live
`libvirtxml.Domain`. The host reaches it via `invokeVmPlugin("resolve-spice", …)`,
passing `Runner.vmTargetName()` (the resolved per-deploy DOMAIN IDENTITY — the deploy name
via `vmDomainIdentity`, not the shared `kind:vm` entity, P33; the plugin prefixes `charly-`).
- The registry dispatch: `providerRegistry.ResolveVerb("spice")` → the
out-of-process `grpcProvider` → `invokeVerbProvider`, which hands the plugin
the full `#Op` as params after the host pre-resolves the endpoint.
## Dependencies
These now live in `candy/plugin-spice`, NOT in charly's core:
- `github.com/Shells-com/spice` (MIT) — SPICE client library, vendored under
`third_party/spice`.
- `github.com/hraban/opus` + `github.com/gordonklaus/portaudio` — cgo audio
transitives, indirect and NOT linked (the plugin is built without
`-tags spice_audio`).
The VM target resolution (go-libvirt / `libvirtxml`) runs OUT-OF-PROCESS in
`candy/plugin-vm/vm_target.go` — the host reaches it via
`invokeVmPlugin("resolve-spice", …)`, not a direct core call, and passes the
resolved per-deploy DOMAIN IDENTITY (`Runner.vmTargetName()` — the deploy name, not the
shared `kind:vm` entity, P33) so the plugin addresses the live domain `charly-<deploy>`,
correctly distinct per bed even when several beds share one entity.
## Related skills
- [`/charly-check:libvirt`](/recipes/check/libvirt/) — the sibling declarative `libvirt:` check verb,
served out-of-process by `candy/plugin-vm` (libvirt RPC: framebuffer
screenshot, send-key, QMP, snapshots, guest agent).
- [`/charly-check:check`](/recipes/check/check/) — the unified check system and the Op (a plan step)
that holds every verb discriminator + modifier.
- [`/charly-vm:arch-cloud-vm`](/recipes/vm/arch-cloud-vm/) — the arch VM that ships SPICE by default; "Connecting from
a remote workstation".
- [`/charly-internals:plugin`](/recipes/internals/plugin/) — the external-charly-verb plugin model the spice
verb follows.
## When to Use This Skill
**MUST be invoked** for any task involving the `spice:` declarative check verb,
SPICE protocol debugging, or proving a libvirt VM's SPICE display path is alive
end-to-end. Invoke this skill BEFORE reading the plugin's Go source.