GNOME Wayland headless RDP (GRD)
Stack: gnome-shell --wayland --headless + gnome-remote-desktop-daemon --headless
Not this stack: xrdp + X11 session (XFCE/Plasma) — see linux-desktop-rdp-container.
GNOME Shell 46+ dropped classic X11 session flags; Shell 50 is Wayland-oriented. Remote access is GRD (FreeRDP server), not xrdp.
Client automation (mstsc Connect / credentials): windows-ui-inject.
Full agent loop (build → connect → drive): agent-workstation-drive.
Scripts in this skill
| Path | Purpose |
|---|---|
scripts/start-gnome-headless-rdp.sh |
Session bring-up: bus wait, grdctl, pipewire, shell, GRD, promote loop |
scripts/promote-rdp-monitor.py |
Meta-1 sole primary after GRD attach |
scripts/configure-desktop-ui.sh |
Wallpaper, dark theme, dash favorites |
scripts/nofuseabort.c |
Source for FUSE LD_PRELOAD stub |
scripts/libnofuseabort.so |
Prebuilt stub (x86_64 Linux) when present |
Copy into the image as /usr/local/bin/…. The entrypoint should mknod /dev/fuse, start dbus/seatd, then runuser this start script. Set GNOME_RDP_PASS (and user/port) from the project.
Architecture
entrypoint (root)
├─ /dev/fuse node if missing (device alone is not enough under seccomp)
├─ system dbus, seatd, polkit (best-effort logind/linger)
└─ runuser → `scripts/start-gnome-headless-rdp.sh`
├─ session dbus on $XDG_RUNTIME_DIR/bus (wait until ListNames works)
├─ grdctl --headless rdp: TLS, credentials, port, enable
├─ pipewire + wireplumber
├─ gnome-shell --wayland --headless --virtual-monitor=WxH
└─ gnome-remote-desktop-daemon --headless (+ watchdog restart)
| Piece | Role |
|---|---|
| Session D-Bus | Shell and GRD die without it; cold start must wait for bus, not race |
| Virtual monitor | Shell creates Meta-* heads; GRD uses virtual monitors for remote |
| PipeWire | Screen cast / stream between Mutter and GRD |
| TLS cert/key | Self-signed under user GRD data dir; client must accept |
| FUSE clipboard | CLIPRDR wants /dev/fuse; container seccomp often EPERM |
Agent duty order (RDP broken)
- Container/server logs first — do not re-click mstsc in a loop
- Confirm listen on the RDP port inside the container (
ss/ healthcheck) - Confirm gnome-shell and gnome-remote-desktop-daemon PIDs
- Confirm session bus +
XDG_RUNTIME_DIR - Only then drive the client UI
Log paths (typical lab): under /tmp/ for headless shell and GRD (name them clearly in the project). Prefer append on GRD restart so connect crashes are not truncated.
Bring-up checklist
[ ] User home + XDG_RUNTIME_DIR owned by desktop user (e.g. uid 1000)
[ ] Session dbus forking and answering ListNames before shell start
[ ] gnome-shell --wayland --headless --virtual-monitor=<W>x<H> stays up
[ ] grdctl --headless: TLS key/cert, credentials, port, rdp enable
[ ] pipewire + wireplumber running as same user
[ ] GRD daemon listening; healthcheck greps the chosen port
[ ] FUSE strategy decided (below) before promising clipboard
grdctl (headless)
Configure as the desktop user with session env set:
rdp set-tls-key/set-tls-certrdp set-credentials <user> <pass>— from env or secrets, not committed literals in skillsrdp set-port <port>— container-internal (commonly RDP well-known 3389); host publish is a run flag, not a skill constantrdp enable/ gsettings headless enable if needed
Host publish (no hard-coded ports)
wslc run -d --name <name> -p <host-port>:<container-rdp-port> -m <memory> <image>
Document <host-port> and credentials in the project README/RDP file. Skills use
placeholders only.
FUSE clipboard (container)
Symptoms:
fuse: failed to open /dev/fuse: Operation not permitted
[FUSE Clipboard] Failed to mount FUSE filesystem
mknod /dev/fuse is not enough if seccomp denies open. GRD starts a FUSE thread that
calls g_error on mount failure. After a demoted log, the thread may still never
complete sync_point_start → peer setup stalls → black RDP while mstsc stays "connected".
Lab fix (proven class of workaround): LD_PRELOAD a small shared object that:
- Stubs
fuse_session_mount→ success - Stubs
fuse_session_loop→ park the thread - Optionally demotes residual FUSE-related
g_logERROR → WARNING
Clipboard redirection will not work; frames and input can. Ship the .so in the image
and set LD_PRELOAD when starting GRD. Prefer this over requiring privileged FUSE when
the runtime (e.g. wslc) exposes no --device/--cap-add.
Dual-head gotcha (wallpaper only on RDP)
Shell often starts with --virtual-monitor → Meta-0. GRD adds Meta-1 (remote) beside it as a second head. Apps land on Meta-0; the client shows Meta-1 → wallpaper/empty.
After connect, promote Meta-1 to sole primary via Mutter DisplayConfig.ApplyMonitorsConfig (see project promote-rdp-monitor.py). Loop every few seconds while shell is up.
Black screen vs dead server
| Observation | Likely cause |
|---|---|
| Nothing listening | Shell/GRD never started; session bus race; entrypoint failure |
| Listen OK, Connect → instant "session ended" | GRD aborted (often FUSE g_error) |
| Connect OK, black, title shows host | FUSE sync stall or graphics/stream not started; check GRD debug |
| Connect OK, solid color / wallpaper, "First frame" in log | Session healthy; buff UI next |
Failed to create EGL thread |
Common without GPU; RFX/software path may still stream via PipeWire |
Healthy connect log signals (names vary slightly by version):
- Creating / accepting RDP session
- Virtual monitors / layout manager
- PipeWire stream → streaming
- RDPGFX CapsAdvertise accepted
- Surface size + First frame metrics
Graphics / EGL notes
GRD may try HW EGL and log failure without GPU (Failed to get EGL display). That alone
is not fatal if PipeWire + RFX/H.264 software paths encode frames. Do not chase GPU in a
CPU-only container until FUSE/session logs are clean.
GNOME / package source
- Stock Azure Linux core may lack a full DE; lab images often pull GNOME 50+ RPMs from
a Fedora version whose repos ship that shell major (pin major with
rpmcheck in image build). - Hard-fail image build if
gnome-shellmajor is below the project requirement (e.g. 50). - Slim install: shell + GRD + seatd + pipewire/wireplumber + mesa dri/egl + fuse3 + fonts + Xwayland as needed — not a full Fedora desktop.
What not to do
- Do not substitute GNOME 49 when the project requires 50
- Do not use xrdp
startwmfor GNOME 50 as the primary path - Do not hard-code host IPs or published ports in skills or shared snippets
- Do not skip logs and only re-run mstsc
- Do not click Learn more when testing clients (see
windows-ui-inject)
Cross-skill map
| Need | Skill |
|---|---|
| Full drive loop / new agent onboarding | agent-workstation-drive |
| mstsc / Windows UI injection | windows-ui-inject |
| XFCE/Plasma + xrdp containers | linux-desktop-rdp-container |
| wslc build/run/push | wsl-containers |
| Plasma Wayland + KRdp | kde-wayland-rdp |
| This stack (Wayland headless GNOME + GRD) | gnome-wayland-rdp |