From ef215c26f74b2b11d54724d92a9fe679e28acf29 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Thu, 15 Oct 2026 02:09:29 +0000 Subject: [PATCH] w3bs fleet desktop: a headless Kubuntu desktop in Docker for AI agents, watched on /fleet (CLI 0.1.7) On a server with no desktop, `w3bs fleet desktop start [--control]` gives it one and shares it on w3bs.org/fleet: press View screen. Asked for while dev3 (Ubuntu 26.04 VM, no GPU) had nothing to show. - desktop/packaging/kubuntu: ubuntu:26.04 image with Kubuntu's KDE (KWin, Plasma, Konsole, KDE portal), TigerVNC 1.16's w0vncserver built from source (Ubuntu ships 1.15, which has no Wayland server), and the Agentic Desktop Layer started Enabled (MCP 127.0.0.1:7802). - No GPU: KWin drives the kernel's vkms virtual display and Mesa composites with OpenGL on the CPU. (KWin nested in Xvnc and on a vgem render node both fall back to QPainter, which has no screencast.) `start` loads vkms via sudo once and persists it in modules-load.d. - VNC only on a 0600 Unix socket in ~/.local/share/w3bs/desktop/vnc: no TCP port, no password. The agent's --vnc now takes unix:/path. - KWIN_WAYLAND_NO_PERMISSION_CHECKS inside the container: Ubuntu's KWin 6.6 does not trust the KDE portal there, so w0vncserver and the desktop layer got no screen; everything in that compositor is the one agent desktop. - `w3bs fleet desktop start|stop|status|logs|agent `; `agent` registers an MCP agent on the desktop and prints the claude mcp add line. Image tag = hash of the embedded build files. - docs/fleet-screen.md: "A headless desktop for AI agents"; /fleet page hints. Proof: on dev3, `w3bs fleet desktop start --control` -> production shows it shared, reachable, listening; the desktop layer typed into Konsole and the VNC socket showed Plasma + the text. Rig (desktop/tests/rig/fleet-screen.sh) 11/11 over TCP and 11/11 with VNC_UNIX=1. CLI tests pass (+3), server tests 32/32. Co-Authored-By: Claude Opus 5.5 --- desktop/packaging/kubuntu/Dockerfile | 53 +++ desktop/packaging/kubuntu/session.sh | 83 +++++ desktop/tests/rig/fleet-screen.sh | 12 +- docs/fleet-screen.md | 52 ++- package-manager/Cargo.lock | 8 +- package-manager/Cargo.toml | 2 +- .../crates/w3bs-cli/src/desktop.rs | 314 ++++++++++++++++++ package-manager/crates/w3bs-cli/src/main.rs | 45 +++ package-manager/crates/w3bs-cli/src/render.rs | 44 +++ package-manager/crates/w3bs-cli/src/screen.rs | 42 ++- src/fleet-routes.mjs | 5 +- 11 files changed, 640 insertions(+), 20 deletions(-) create mode 100644 desktop/packaging/kubuntu/Dockerfile create mode 100644 desktop/packaging/kubuntu/session.sh create mode 100644 package-manager/crates/w3bs-cli/src/desktop.rs diff --git a/desktop/packaging/kubuntu/Dockerfile b/desktop/packaging/kubuntu/Dockerfile new file mode 100644 index 0000000..8fe045a --- /dev/null +++ b/desktop/packaging/kubuntu/Dockerfile @@ -0,0 +1,53 @@ +# syntax=docker/dockerfile:1 +# A headless Kubuntu desktop for AI agents, watched from w3bs.org/fleet. +# +# KWin (Wayland) drives a virtual display: the host's vkms device, a KMS display with no +# hardware behind it, so Mesa composites with OpenGL on the CPU (llvmpipe) and no GPU is +# needed. Plasma, Konsole and the Agentic Desktop Layer (`w3bs-desktop`, Enabled from the +# start, MCP on 127.0.0.1:7801) run on that KWin. TigerVNC's w0vncserver shares the session +# through the KDE portal on a Unix socket in /vnc, which the host's `w3bs fleet agent` relays +# to w3bs.org; no TCP port is opened. +# +# Built and run by `w3bs fleet desktop start`; see w3bs.org/docs/fleet-screen.md. + +# Ubuntu packages TigerVNC 1.15, which has no Wayland server; w0vncserver arrived in 1.16. +FROM ubuntu:26.04 AS w0vncserver +ENV DEBIAN_FRONTEND=noninteractive +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential cmake pkg-config ca-certificates curl \ + zlib1g-dev libpixman-1-dev libjpeg-turbo8-dev libglib2.0-dev libpipewire-0.3-dev uuid-dev \ + libwayland-dev libwayland-bin wayland-protocols libxkbcommon-dev libpam0g-dev \ + libx11-dev libxext-dev libxtst-dev libxdamage-dev libxfixes-dev libxrandr-dev libxi-dev libxcursor-dev +ARG TIGERVNC_VERSION=1.16.2 +ARG TIGERVNC_SHA256=b107c0c8b8a962594281690366c24186e95c2ea4a169acbc0076aa62ed01f467 +RUN curl -fsSL -o /tmp/tigervnc.tar.gz "https://github.com/TigerVNC/tigervnc/archive/refs/tags/v${TIGERVNC_VERSION}.tar.gz" \ + && echo "${TIGERVNC_SHA256} /tmp/tigervnc.tar.gz" | sha256sum -c - \ + && mkdir /src && tar xzf /tmp/tigervnc.tar.gz -C /src --strip-components=1 +WORKDIR /build +RUN cmake /src -DCMAKE_BUILD_TYPE=Release -DBUILD_VIEWER=OFF -DENABLE_NLS=OFF -DENABLE_GNUTLS=OFF \ + -DENABLE_NETTLE=OFF -DENABLE_H264=OFF -DENABLE_WAYLAND=ON -DINSTALL_SYSTEMD_UNITS=OFF \ + && make -j"$(nproc)" w0vncserver && install -m 0755 unix/w0vncserver/w0vncserver /usr/local/bin/ + +FROM ubuntu:26.04 +ARG DESKTOP_VERSION=0.3.0 +ENV DEBIAN_FRONTEND=noninteractive LANG=C.UTF-8 +RUN apt-get update && apt-get install -y --no-install-recommends \ + kwin-wayland plasma-desktop plasma-workspace konsole breeze breeze-cursor-theme libkscreen-bin \ + xdg-desktop-portal xdg-desktop-portal-kde pipewire wireplumber dbus-daemon flatpak \ + libegl-mesa0 libgl1-mesa-dri libpipewire-0.3-0t64 libjpeg-turbo8 libpixman-1-0 libpam0g \ + fonts-noto-core ca-certificates curl tini procps \ + && rm -rf /var/lib/apt/lists/* +# KWin asks for real-time scheduling, which a container's capability set does not grant. +RUN setcap -r /usr/bin/kwin_wayland 2>/dev/null || true +COPY --from=w0vncserver /usr/local/bin/w0vncserver /usr/local/bin/w0vncserver +RUN arch="$(uname -m)" \ + && base="https://github.com/profullstack/w3bs.org/releases/download/desktop-v${DESKTOP_VERSION}" \ + && file="w3bs-desktop-${DESKTOP_VERSION}-${arch}-unknown-linux-gnu.tar.gz" \ + && cd /tmp && curl -fsSLO "$base/$file" && curl -fsSLO "$base/SHA256SUMS" \ + && grep " $file\$" SHA256SUMS | sha256sum -c - \ + && tar xzf "$file" ./bin/w3bs-desktop && install -m 0755 bin/w3bs-desktop /usr/local/bin/ \ + && rm -rf /tmp/* +COPY session.sh /usr/local/bin/w3bs-kubuntu-session +RUN chmod 0755 /usr/local/bin/w3bs-kubuntu-session && mkdir -p /vnc /home/desktop && chmod 1777 /vnc /home/desktop +ENV HOME=/home/desktop +ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/w3bs-kubuntu-session"] diff --git a/desktop/packaging/kubuntu/session.sh b/desktop/packaging/kubuntu/session.sh new file mode 100644 index 0000000..3b92c8d --- /dev/null +++ b/desktop/packaging/kubuntu/session.sh @@ -0,0 +1,83 @@ +#!/bin/sh +# The headless Kubuntu session inside the w3bs desktop container (see Dockerfile). +# Runs as the host user (docker run --user), with $HOME on a host volume. +# W3BS_DRM the KMS device KWin drives (default: the vkms card passed in) +# W3BS_GEOMETRY 1920x1080 by default +# W3BS_MCP 127.0.0.1:7801 by default (the desktop layer's MCP endpoint) +set -eu +GEOMETRY=${W3BS_GEOMETRY:-1920x1080} +export XDG_RUNTIME_DIR=/tmp/runtime-$(id -u) +export DBUS_SESSION_BUS_ADDRESS=unix:path=$XDG_RUNTIME_DIR/bus +export XDG_CURRENT_DESKTOP=KDE XDG_SESSION_TYPE=wayland XDG_SESSION_DESKTOP=KDE KDE_FULL_SESSION=true +export XDG_CONFIG_HOME=$HOME/.config XDG_DATA_HOME=$HOME/.local/share +# The host user has no passwd entry in the image, so name the shell Konsole should start. +export SHELL=/bin/bash +LOGS=$HOME/.local/state/w3bs-desktop/logs +mkdir -p -m 0700 "$XDG_RUNTIME_DIR" && mkdir -p "$LOGS" "$XDG_CONFIG_HOME/xdg-desktop-portal" +printf '[preferred]\ndefault=kde;gtk\n' > "$XDG_CONFIG_HOME/xdg-desktop-portal/kde-portals.conf" +rm -f /vnc/vnc.sock + +say() { echo "w3bs-kubuntu: $*"; } +wait_for() { for _ in $(seq 1 100); do [ -e "$1" ] && return 0; sleep 0.1; done; say "timed out waiting for $1"; exit 1; } + +# The display: the vkms card the host passed in (`w3bs fleet desktop start` finds it). +DRM=${W3BS_DRM:-} +if [ -z "$DRM" ]; then + for card in /dev/dri/card*; do + [ "$(basename "$(readlink -f "/sys/class/drm/$(basename "$card")/device")")" = vkms ] && DRM=$card && break + done +fi +[ -n "$DRM" ] || { say "no vkms display passed in; on the host run: sudo modprobe vkms"; exit 2; } + +dbus-daemon --session --address="$DBUS_SESSION_BUS_ADDRESS" --nofork --nopidfile >"$LOGS/dbus.log" 2>&1 & +wait_for "$XDG_RUNTIME_DIR/bus" +pipewire >"$LOGS/pipewire.log" 2>&1 & +wireplumber >"$LOGS/wireplumber.log" 2>&1 & + +# KWin hands its screencast and fake-input protocols only to clients it trusts by their desktop +# file. Ubuntu's KWin 6.6 does not trust the KDE portal inside this container (it does on +# Fedora's 6.7), so the portal, and with it w0vncserver and the desktop layer, would get no +# screen. Everything in this compositor is part of one agent desktop that the desktop layer +# already lets agents see and drive, so turn the check off here; it never touches the host. +export KWIN_WAYLAND_NO_PERMISSION_CHECKS=1 +kbuildsycoca6 >"$LOGS/kbuildsycoca.log" 2>&1 || true + +# KWin on the virtual display; with no GPU, Mesa renders OpenGL on the CPU. +KWIN_DRM_DEVICES=$DRM kwin_wayland --drm --socket wayland-0 >"$LOGS/kwin.log" 2>&1 & +KWIN=$! +wait_for "$XDG_RUNTIME_DIR/wayland-0" +export WAYLAND_DISPLAY=wayland-0 QT_QPA_PLATFORM=wayland +# vkms starts at 1024x768. +for _ in $(seq 1 20); do kscreen-doctor -o >/dev/null 2>&1 && break; sleep 0.5; done +OUTPUT=$(kscreen-doctor -j 2>/dev/null | sed -n 's/.*"name": *"\([^"]*\)".*/\1/p' | head -1) +[ -n "$OUTPUT" ] && kscreen-doctor "output.$OUTPUT.mode.$GEOMETRY@60" >"$LOGS/kscreen.log" 2>&1 \ + || say "could not set $GEOMETRY on ${OUTPUT:-the display}; see $LOGS/kscreen.log" + +/usr/lib/*/libexec/xdg-desktop-portal-kde >"$LOGS/xdg-desktop-portal-kde.log" 2>&1 & +sleep 1 +/usr/libexec/xdg-desktop-portal -r >"$LOGS/xdg-desktop-portal.log" 2>&1 & +sleep 1 +# Nobody can click a consent dialog on a headless desktop: grant remote control and screenshots +# up front, the way KDE intends for unattended machines. w0vncserver and the desktop layer each +# open their own portal session against this grant. +flatpak permission-set kde-authorized remote-desktop "" yes +flatpak permission-set screenshot screenshot "" yes + +plasmashell >"$LOGS/plasmashell.log" 2>&1 & +konsole >"$LOGS/konsole.log" 2>&1 & + +# The VNC server: a Unix socket only, owner-only (0600), no password (the socket is the lock). +w0vncserver -rfbport -1 -rfbunixpath /vnc/vnc.sock -SecurityTypes None -AlwaysShared \ + -desktop "w3bs desktop" >"$LOGS/w0vncserver.log" 2>&1 & +W0=$! + +# The Agentic Desktop Layer, Enabled from the start: this desktop exists for agents. +w3bs-desktop daemon --adapter auto --enabled-by-default --mcp-http "${W3BS_MCP:-127.0.0.1:7801}" \ + >"$LOGS/w3bs-desktop.log" 2>&1 & +say "up: KWin on $DRM, VNC on /vnc/vnc.sock, desktop layer MCP on http://${W3BS_MCP:-127.0.0.1:7801}/mcp" + +# Stay up while the compositor and the VNC server live (docker restarts the container +# otherwise); tini reaps everything else, including w0vncserver's per-viewer children. +while kill -0 "$KWIN" 2>/dev/null && kill -0 "$W0" 2>/dev/null; do sleep 5; done +say "KWin or w0vncserver exited; see $LOGS" +exit 1 diff --git a/desktop/tests/rig/fleet-screen.sh b/desktop/tests/rig/fleet-screen.sh index 791bfde..0100ed6 100755 --- a/desktop/tests/rig/fleet-screen.sh +++ b/desktop/tests/rig/fleet-screen.sh @@ -5,6 +5,7 @@ # # desktop/tests/rig/fleet-screen.sh [SCRATCH_DIR] # W3BS_BIN=path/to/w3bs KEEP=1 desktop/tests/rig/fleet-screen.sh +# VNC_UNIX=1 desktop/tests/rig/fleet-screen.sh # VNC on a Unix socket, as `w3bs fleet desktop` does # # Checks: the page shows the remote desktop; in view mode, input the page sends is dropped by # the agent (the page's own switch is forced on); after `w3bs fleet screen on --control`, @@ -21,6 +22,11 @@ NAME=${NAME:-adl-fleet-screen} IMAGE=${IMAGE:-fedora:44} PORT=${PORT:-3377} VNC_PORT=${VNC_PORT:-5937} +if [ "${VNC_UNIX:-0}" = 1 ]; then + VNC_LISTEN="-rfbport -1 -rfbunixpath /rig/vnc.sock"; VNC_ADDR=unix:/rig/vnc.sock +else + VNC_LISTEN="-localhost -rfbport $VNC_PORT"; VNC_ADDR=127.0.0.1:$VNC_PORT +fi W3BS_BIN=${W3BS_BIN:-${CARGO_TARGET_DIR:-$REPO/package-manager/target}/debug/w3bs} RENDER=${RENDER:-$(ls /dev/dri/renderD* 2>/dev/null | head -1)} [ -n "$RENDER" ] || { echo "no /dev/dri/renderD* on this host; KWin cannot screencast without OpenGL" >&2; exit 2; } @@ -88,10 +94,10 @@ sleep 2 mkdir -p /root/.config/tigervnc echo '$VNC_PASSWORD' | vncpasswd -f > /root/.config/tigervnc/passwd chmod 600 /root/.config/tigervnc/passwd -setsid w0vncserver -localhost -rfbport $VNC_PORT -SecurityTypes VncAuth \\ +setsid w0vncserver $VNC_LISTEN -SecurityTypes VncAuth \\ -PasswordFile /root/.config/tigervnc/passwd -AlwaysShared >\$L/w0vncserver.log 2>&1 & sleep 1 -w3bs fleet join $TOKEN --api http://localhost:$PORT --no-service --screen view --vnc 127.0.0.1:$VNC_PORT +w3bs fleet join $TOKEN --api http://localhost:$PORT --no-service --screen view --vnc $VNC_ADDR setsid w3bs fleet agent >\$L/agent.log 2>&1 & EOF chmod +x "$RIG"/*.sh @@ -110,7 +116,7 @@ for i in $(seq 1 20); do grep -q "input messages dropped" "$RIG/logs/agent.log" grep -q "input messages dropped (view only)" "$RIG/logs/agent.log" && ok "agent: $(grep -m1 'input messages dropped' "$RIG/logs/agent.log" | sed 's/^w3bs fleet agent: //')" || bad "agent log: $(tail -5 "$RIG/logs/agent.log")" echo "== control: w3bs fleet screen on --control, then typing lands" -docker exec "$NAME" sh -c ". /rig/env.sh; w3bs fleet screen on --control --vnc 127.0.0.1:$VNC_PORT" >"$RIG/logs/screen-on.log" 2>&1 && ok "$(head -1 "$RIG/logs/screen-on.log")" || bad "screen on: $(cat "$RIG/logs/screen-on.log")" +docker exec "$NAME" sh -c ". /rig/env.sh; w3bs fleet screen on --control --vnc $VNC_ADDR" >"$RIG/logs/screen-on.log" 2>&1 && ok "$(head -1 "$RIG/logs/screen-on.log")" || bad "screen on: $(cat "$RIG/logs/screen-on.log")" for i in $(seq 1 90); do machines | grep -q '"mode":"control"' && machines | grep -q '"screen_listening":true' && break; sleep 0.5; done machines | grep -q '"mode":"control"' && ok "agent picked up control mode without a restart" || bad "mode: $(machines | head -c 400)" (cd "$REPO" && node "$HERE/fleet-screen-browser.mjs" control "$RIG/state.json" "$RIG/shots" "typed-through-w3bs") >"$RIG/logs/browser-control.log" 2>&1 \ diff --git a/docs/fleet-screen.md b/docs/fleet-screen.md index 9882bb3..ac613c4 100644 --- a/docs/fleet-screen.md +++ b/docs/fleet-screen.md @@ -5,10 +5,14 @@ running the [Agentic Desktop Layer](/desktop), a machine in another room), open **w3bs.org/fleet**, pick the machine and press **View screen**. The page shows its desktop live in the browser (noVNC), view only unless you allow control. +**A server with no desktop at all?** One command gives it one: see +[A headless desktop for AI agents](#a-headless-desktop-for-ai-agents). + ## How it works ``` -browser (noVNC) --wss--> w3bs.org relay <--wss-- w3bs fleet agent --tcp--> local VNC server +browser (noVNC) --wss--> w3bs.org relay <--wss-- w3bs fleet agent --> local VNC server + (TCP or a Unix socket) ``` - **Nothing on the machine listens to the network.** The fleet agent (`w3bs fleet agent`, the @@ -31,7 +35,50 @@ browser (noVNC) --wss--> w3bs.org relay <--wss-- w3bs fleet agent --tcp--> local - **The VNC password stays in your browser.** VNC authentication is a challenge and response; w3bs.org relays the response and never sees the password. -## Turn it on +## A headless desktop for AI agents + +On a Linux server with Docker and no desktop, after `w3bs fleet join`: + +```sh +w3bs fleet desktop start # view only from /fleet +w3bs fleet desktop start --control # also mouse and keyboard from /fleet +``` + +That is all. It runs a **Kubuntu desktop in a Docker container** (`w3bs-desktop`) and shares its +screen on /fleet: press **View screen**. The first start builds the image (a few minutes); +after that it starts in seconds, and Docker restarts it with the machine. + +What is in the container: + +- **KDE Plasma** (KWin, Plasma shell, Konsole) from Ubuntu 26.04's packages. +- **No GPU needed.** KWin drives the kernel's `vkms` virtual display and Mesa renders OpenGL on + the CPU. `start` loads `vkms` (asking `sudo` once) and keeps it loaded across reboots. +- **The [Agentic Desktop Layer](/desktop)**, enabled from the start, with its MCP endpoint on + `http://127.0.0.1:7802/mcp` (not 7801, so it never clashes with a desktop layer on the host). +- **TigerVNC's `w0vncserver`**, built from source in the image (Ubuntu's TigerVNC is too old to + have it), listening only on a Unix socket in `~/.local/share/w3bs/desktop/vnc/` that only + your user can open. No TCP port, no VNC password to manage. +- Its home directory is `~/.local/share/w3bs/desktop/home` on the host, kept across restarts. + +Give an AI agent the desktop: + +```sh +w3bs fleet desktop agent claude # registers it, prints the `claude mcp add ...` line +w3bs fleet desktop agent watcher --preset observe # look, never touch +``` + +Run the printed line on the same machine; the agent then sees and drives the Kubuntu desktop +through MCP, and you watch it on /fleet. + +```sh +w3bs fleet desktop status # running? shared? image current? +w3bs fleet desktop logs # container log; session logs are under home/.local/state +w3bs fleet desktop start --rebuild # rebuild the image (after a w3bs update changes it) +w3bs fleet desktop start --geometry 2560x1440 +w3bs fleet desktop stop # remove the container and stop sharing; home is kept +``` + +## Turn it on (your own VNC server) On the machine, after it joined your fleet: @@ -39,6 +86,7 @@ On the machine, after it joined your fleet: w3bs fleet screen on # view only, VNC server on 127.0.0.1:5900 w3bs fleet screen on --control # also allow mouse and keyboard from the page w3bs fleet screen on --vnc 127.0.0.1:5901 +w3bs fleet screen on --vnc unix:/run/user/1000/vnc.sock # a Unix socket w3bs fleet screen # show the setting and whether the VNC server answers w3bs fleet screen off # stop; open sessions end within one report (30 s) ``` diff --git a/package-manager/Cargo.lock b/package-manager/Cargo.lock index b6e3ccd..a2c558c 100644 --- a/package-manager/Cargo.lock +++ b/package-manager/Cargo.lock @@ -2159,7 +2159,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "w3bs-cli" -version = "0.1.6" +version = "0.1.7" dependencies = [ "aws-lc-rs", "clap", @@ -2186,7 +2186,7 @@ dependencies = [ [[package]] name = "w3bs-core" -version = "0.1.6" +version = "0.1.7" dependencies = [ "async-trait", "aws-lc-rs", @@ -2216,7 +2216,7 @@ dependencies = [ [[package]] name = "w3bs-publish" -version = "0.1.6" +version = "0.1.7" dependencies = [ "anyhow", "aws-lc-rs", @@ -2235,7 +2235,7 @@ dependencies = [ [[package]] name = "w3bs-schema" -version = "0.1.6" +version = "0.1.7" dependencies = [ "hex", "semver", diff --git a/package-manager/Cargo.toml b/package-manager/Cargo.toml index 7f19377..41c84a2 100644 --- a/package-manager/Cargo.toml +++ b/package-manager/Cargo.toml @@ -3,7 +3,7 @@ resolver = "3" members = ["crates/w3bs-schema", "crates/w3bs-core", "crates/w3bs-cli", "crates/w3bs-publish"] [workspace.package] -version = "0.1.6" +version = "0.1.7" edition = "2024" license = "MIT OR Apache-2.0" repository = "https://github.com/profullstack/w3bs.org" diff --git a/package-manager/crates/w3bs-cli/src/desktop.rs b/package-manager/crates/w3bs-cli/src/desktop.rs new file mode 100644 index 0000000..c1be824 --- /dev/null +++ b/package-manager/crates/w3bs-cli/src/desktop.rs @@ -0,0 +1,314 @@ +//! `w3bs fleet desktop`: a headless Kubuntu desktop in Docker, for AI agents to work on and for +//! the fleet owner to watch on w3bs.org/fleet. +//! +//! `start` builds the image from the Dockerfile embedded here (desktop/packaging/kubuntu), runs +//! it as this user with `--restart unless-stopped`, and turns screen sharing on for this +//! machine's fleet agent through the container's VNC Unix socket. Inside: KWin on the kernel's +//! vkms virtual display (OpenGL on the CPU, no GPU needed), Plasma, Konsole, TigerVNC's +//! w0vncserver and the Agentic Desktop Layer (MCP on 127.0.0.1:7802). See +//! w3bs.org/docs/fleet-screen.md. + +use crate::fleet; +use crate::screen::{self, Mode, Screen}; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; +use w3bs_core::{Error, Result}; + +pub const CONTAINER: &str = "w3bs-desktop"; +pub const IMAGE: &str = "w3bs-kubuntu-desktop"; +/// Not 7801, which a desktop layer running natively on the same machine uses. +pub const MCP: &str = "127.0.0.1:7802"; +const DOCKERFILE: &str = include_str!("../../../../desktop/packaging/kubuntu/Dockerfile"); +const SESSION: &str = include_str!("../../../../desktop/packaging/kubuntu/session.sh"); +const START_TIMEOUT: Duration = Duration::from_secs(120); + +fn fail(code: &'static str, msg: impl Into) -> Error { + Error::package(code, msg.into()) +} + +/// `$XDG_DATA_HOME/w3bs/desktop`: the desktop's home (`home/`) and its VNC socket (`vnc/`). +pub fn data_dir() -> Result { + let base = std::env::var_os("XDG_DATA_HOME") + .filter(|x| !x.is_empty()) + .map(PathBuf::from) + .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/share"))) + .ok_or_else(|| fail("NO_HOME", "HOME is not set"))?; + Ok(base.join("w3bs/desktop")) +} + +pub fn socket_path() -> Result { + Ok(data_dir()?.join("vnc/vnc.sock")) +} + +/// The image tag: a hash of the embedded build files, so a w3bs update that changes them +/// rebuilds and an unchanged one reuses the image. +pub fn image_tag() -> String { + let mut h = Sha256::new(); + h.update(DOCKERFILE); + h.update([0]); + h.update(SESSION); + let hex: String = h.finalize().iter().take(6).map(|b| format!("{b:02x}")).collect(); + format!("{IMAGE}:{hex}") +} + +fn docker(args: &[&str]) -> std::result::Result { + match Command::new("docker").args(args).stdin(Stdio::null()).output() { + Ok(o) if o.status.success() => Ok(String::from_utf8_lossy(&o.stdout).trim().to_owned()), + Ok(o) => Err(String::from_utf8_lossy(&o.stderr).trim().to_owned()), + Err(e) => Err(e.to_string()), + } +} + +fn require_docker() -> Result<()> { + match docker(&["version", "--format", "{{.Server.Version}}"]) { + Ok(_) => Ok(()), + Err(e) if e.contains("permission denied") => Err(fail( + "DOCKER_PERMISSION", + format!("this user cannot use Docker; add it to the docker group and log in again: sudo usermod -aG docker {}", std::env::var("USER").unwrap_or_default()), + )), + Err(e) if e.contains("No such file") || e.contains("not found") => Err(fail("NO_DOCKER", "Docker is not installed (https://docs.docker.com/engine/install/)")), + Err(e) => Err(fail("DOCKER_UNAVAILABLE", format!("Docker is not answering: {e}"))), + } +} + +/// The vkms card (`/dev/dri/cardN`): its sysfs device is named vkms (on the faux bus in recent +/// kernels, a platform device in older ones). +pub fn find_vkms(sys: &Path) -> Option { + let mut cards: Vec<_> = std::fs::read_dir(sys).ok()?.flatten().map(|e| e.file_name().to_string_lossy().into_owned()).filter(|n| n.starts_with("card") && n[4..].chars().all(|c| c.is_ascii_digit()) && n.len() > 4).collect(); + cards.sort(); + cards.into_iter().find(|n| std::fs::canonicalize(sys.join(n).join("device")).ok().and_then(|p| p.file_name().map(|f| f == "vkms")).unwrap_or(false)).map(|n| PathBuf::from("/dev/dri").join(n)) +} + +#[cfg(unix)] +fn ids() -> (u32, u32) { + // SAFETY: getuid/getgid have no preconditions. + unsafe { (libc::getuid(), libc::getgid()) } +} +#[cfg(not(unix))] +fn ids() -> (u32, u32) { + (0, 0) +} + +/// Load vkms (and keep it across reboots), asking sudo for it when needed. +fn ensure_vkms(notes: &mut Vec) -> Result { + let sys = Path::new("/sys/class/drm"); + if let Some(card) = find_vkms(sys) { + return Ok(card); + } + let root = ids().0 == 0; + let run = |args: &[&str]| -> bool { + let mut cmd = if root { Command::new(args[0]) } else { Command::new("sudo") }; + if root { + cmd.args(&args[1..]); + } else { + cmd.args(args); + } + cmd.status().map(|s| s.success()).unwrap_or(false) + }; + eprintln!("w3bs: loading the vkms virtual display (a kernel module; sudo may ask for your password)"); + let loaded = run(&["modprobe", "vkms"]); + let persisted = loaded && run(&["sh", "-c", "echo vkms > /etc/modules-load.d/w3bs-vkms.conf"]); + if persisted { + notes.push("loaded the vkms kernel module and kept it in /etc/modules-load.d/w3bs-vkms.conf".into()); + } + for _ in 0..20 { + if let Some(card) = find_vkms(sys) { + return Ok(card); + } + std::thread::sleep(Duration::from_millis(250)); + } + Err(fail( + "NO_VKMS", + "the desktop needs the kernel's vkms virtual display; run: sudo modprobe vkms && echo vkms | sudo tee /etc/modules-load.d/w3bs-vkms.conf", + )) +} + +#[cfg(unix)] +fn gid_of(path: &Path) -> Option { + use std::os::unix::fs::MetadataExt; + std::fs::metadata(path).ok().map(|m| m.gid()) +} +#[cfg(not(unix))] +fn gid_of(_: &Path) -> Option { + None +} + +fn build_image(tag: &str) -> Result<()> { + let ctx = data_dir()?.join("build"); + std::fs::create_dir_all(&ctx).map_err(|e| fail("DESKTOP_BUILD", format!("{}: {e}", ctx.display())))?; + std::fs::write(ctx.join("Dockerfile"), DOCKERFILE).and_then(|_| std::fs::write(ctx.join("session.sh"), SESSION)).map_err(|e| fail("DESKTOP_BUILD", e.to_string()))?; + eprintln!("w3bs: building the Kubuntu desktop image {tag} (first time only, a few minutes)"); + let ok = Command::new("docker") + .args(["build", "--quiet", "-t", tag, "-t", &format!("{IMAGE}:latest")]) + .arg(&ctx) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false); + if !ok { + return Err(fail("DESKTOP_BUILD", format!("docker build failed; retry, or build by hand: docker build -t {tag} {}", ctx.display()))); + } + Ok(()) +} + +/// Point this machine's fleet agent at the desktop (or away from it). Ok(None) when the +/// machine is not in a fleet. +fn share(screen: Option) -> Result> { + let path = fleet::config_path(None)?; + if fleet::load_config(&path)?.is_none() { + return Ok(None); + } + fleet::screen_set(None, Some(screen)).map(Some) +} + +pub fn start(control: bool, geometry: Option<&str>, rebuild: bool) -> Result { + if let Some(g) = geometry { + let ok = g.split_once('x').is_some_and(|(w, h)| w.parse::().is_ok_and(|w| w >= 640) && h.parse::().is_ok_and(|h| h >= 480)); + if !ok { + return Err(Error::usage("BAD_GEOMETRY", format!("{g:?} is not WIDTHxHEIGHT, e.g. 1920x1080"))); + } + } + require_docker()?; + let mut notes = Vec::new(); + let card = ensure_vkms(&mut notes)?; + let tag = image_tag(); + if rebuild || docker(&["image", "inspect", &tag]).is_err() { + build_image(&tag)?; + } + let dir = data_dir()?; + for sub in ["home", "vnc"] { + let mut b = std::fs::DirBuilder::new(); + b.recursive(true); + #[cfg(unix)] + std::os::unix::fs::DirBuilderExt::mode(&mut b, 0o700); + b.create(dir.join(sub)).map_err(|e| fail("DESKTOP_DIR", format!("{}: {e}", dir.join(sub).display())))?; + } + let sock = socket_path()?; + let _ = std::fs::remove_file(&sock); + let _ = docker(&["rm", "-f", CONTAINER]); + let (uid, gid) = ids(); + let user = format!("{uid}:{gid}"); + let device = card.display().to_string(); + let card_gid = gid_of(&card).map(|g| g.to_string()); + let home = format!("{}:/home/desktop", dir.join("home").display()); + let vnc = format!("{}:/vnc", dir.join("vnc").display()); + let geometry_env = format!("W3BS_GEOMETRY={}", geometry.unwrap_or("1920x1080")); + let mcp_env = format!("W3BS_MCP={MCP}"); + let mut args = vec!["run", "-d", "--name", CONTAINER, "--restart", "unless-stopped", "--network", "host", "--user", &user, "--shm-size", "512m", "--device", &device]; + if let Some(g) = card_gid.as_deref() { + args.extend(["--group-add", g]); + } + args.extend(["-e", &geometry_env, "-e", &mcp_env, "-v", &home, "-v", &vnc, &tag]); + docker(&args).map_err(|e| fail("DESKTOP_START", format!("docker run failed: {e}")))?; + + let started = Instant::now(); + while !sock.exists() { + if started.elapsed() > START_TIMEOUT { + return Err(fail("DESKTOP_START", format!("the desktop did not come up in {}s; see `w3bs fleet desktop logs`", START_TIMEOUT.as_secs()))); + } + if docker(&["inspect", "-f", "{{.State.Running}}", CONTAINER]).as_deref() != Ok("true") && started.elapsed() > Duration::from_secs(5) { + return Err(fail("DESKTOP_START", "the desktop container stopped; see `w3bs fleet desktop logs`")); + } + std::thread::sleep(Duration::from_millis(500)); + } + let vnc_addr = format!("unix:{}", sock.display()); + let shared = share(Some(Screen { mode: if control { Mode::Control } else { Mode::View }, vnc: vnc_addr.clone() }))?; + if shared.is_none() { + notes.push(format!("this machine is not in a fleet, so nothing shares the screen yet; join it, then run: w3bs fleet screen on --vnc {vnc_addr}")); + } + Ok(json!({ + "container": CONTAINER, "image": tag, "display": device, "vnc": vnc_addr, + "mcp": format!("http://{MCP}/mcp"), "home": dir.join("home"), + "screen": shared.as_ref().map(|s| &s["screen"]), "url": shared.as_ref().map(|s| &s["url"]), + "notes": notes, + })) +} + +pub fn stop() -> Result { + require_docker()?; + let removed = docker(&["rm", "-f", CONTAINER]).is_ok(); + // Stop sharing only if the agent was sharing this desktop. + let path = fleet::config_path(None)?; + let ours = format!("unix:{}", socket_path()?.display()); + let unshared = match fleet::load_config(&path)? { + Some(c) if c.screen.as_ref().is_some_and(|s| s.vnc == ours) => share(None)?.is_some(), + _ => false, + }; + Ok(json!({ "container": CONTAINER, "removed": removed, "screen_off": unshared })) +} + +pub fn status() -> Result { + require_docker()?; + let state = docker(&["inspect", "-f", "{{.State.Status}} {{.State.StartedAt}} {{.RestartCount}} {{.Config.Image}}", CONTAINER]).ok(); + let mut parts = state.as_deref().unwrap_or_default().split(' '); + let sock = socket_path()?; + let agent_screen = fleet::load_config(&fleet::config_path(None)?)?.and_then(|c| c.screen).map(|s| screen::info(&s)); + Ok(json!({ + "container": CONTAINER, + "state": parts.next().filter(|s| !s.is_empty()).unwrap_or("absent"), + "started_at": parts.next(), "restarts": parts.next(), "image": parts.next(), + "current_image": image_tag(), "vnc": format!("unix:{}", sock.display()), "vnc_ready": sock.exists(), + "mcp": format!("http://{MCP}/mcp"), "vkms": find_vkms(Path::new("/sys/class/drm")), + "screen": agent_screen, "logs": data_dir()?.join("home/.local/state/w3bs-desktop/logs"), + })) +} + +pub fn logs() -> Result { + require_docker()?; + let out = Command::new("docker").args(["logs", "--tail", "40", CONTAINER]).output().map_err(|e| fail("DOCKER_UNAVAILABLE", e.to_string()))?; + let text = format!("{}{}", String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr)); + Ok(json!({ "container": CONTAINER, "log": text, "session_logs": data_dir()?.join("home/.local/state/w3bs-desktop/logs") })) +} + +/// Register an agent on the desktop's layer and give the MCP line for it, with the token file +/// as seen from the host. +pub fn agent(name: &str, preset: &str) -> Result { + require_docker()?; + if name.is_empty() || !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') { + return Err(Error::usage("BAD_AGENT", "agent names are letters, digits, - and _")); + } + let (uid, _) = ids(); + let runtime = format!("XDG_RUNTIME_DIR=/tmp/runtime-{uid}"); + let out = docker(&["exec", "-e", &runtime, CONTAINER, "w3bs-desktop", "agents", "add", name, "--preset", preset, "--json"]) + .map_err(|e| fail("DESKTOP_AGENT", format!("could not register {name} (is the desktop running? `w3bs fleet desktop status`): {e}")))?; + let v: Value = serde_json::from_str(&out).unwrap_or(Value::Null); + let inner = v["token_file"].as_str().ok_or_else(|| fail("DESKTOP_AGENT", format!("unexpected answer from the desktop layer: {out}")))?; + let host_token = data_dir()?.join("home").join(inner.trim_start_matches("/home/desktop/")); + let claude = format!("claude mcp add --transport http w3bs-desktop http://{MCP}/mcp --header \"Authorization: Bearer $(cat '{}')\"", host_token.display()); + Ok(json!({ "agent": name, "preset": preset, "token_file": host_token, "mcp": format!("http://{MCP}/mcp"), "claude_code": claude })) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn finds_vkms_among_cards() { + let dir = tempfile::tempdir().unwrap(); + let sys = dir.path().join("class/drm"); + let devs = dir.path().join("devices"); + for (card, dev) in [("card0", "pci0000:00/0000:00:02.0"), ("card1", "faux/vgem"), ("card2", "faux/vkms")] { + std::fs::create_dir_all(devs.join(dev)).unwrap(); + std::fs::create_dir_all(sys.join(card)).unwrap(); + #[cfg(unix)] + std::os::unix::fs::symlink(devs.join(dev), sys.join(card).join("device")).unwrap(); + } + std::fs::create_dir_all(sys.join("card2-Virtual-2")).unwrap(); + #[cfg(unix)] + assert_eq!(find_vkms(&sys), Some(PathBuf::from("/dev/dri/card2"))); + assert_eq!(find_vkms(&dir.path().join("nothing")), None); + } + + #[test] + fn image_tag_follows_the_build_files() { + let t = image_tag(); + assert!(t.starts_with("w3bs-kubuntu-desktop:")); + assert_eq!(t.len(), "w3bs-kubuntu-desktop:".len() + 12); + assert_eq!(t, image_tag()); + assert!(DOCKERFILE.contains("w0vncserver") && SESSION.contains("/vnc/vnc.sock")); + } +} diff --git a/package-manager/crates/w3bs-cli/src/main.rs b/package-manager/crates/w3bs-cli/src/main.rs index 6881aa9..e1a4221 100644 --- a/package-manager/crates/w3bs-cli/src/main.rs +++ b/package-manager/crates/w3bs-cli/src/main.rs @@ -2,6 +2,7 @@ //! manager is the first app, W3BS resources (the w3bs:// registry) the second. mod auth; +mod desktop; mod fleet; mod screen; mod render; @@ -33,6 +34,7 @@ Apps: w3bs fleet run queue refresh, update or restart-desktop on a machine w3bs fleet screen on let the fleet owner watch this desktop (VNC, view only) w3bs fleet view watch a machine's desktop in the browser + w3bs fleet desktop start a headless Kubuntu desktop (Docker) for AI agents, shared there w3bs fleet --help status, agent, leave, rm"; const AFTER_HELP: &str = "\ @@ -393,6 +395,12 @@ enum FleetCmd { #[arg(long, hide = true, value_name = "PATH", global = true)] config: Option, }, + /// A headless Kubuntu desktop in Docker for AI agents to work on, shared on w3bs.org/fleet + /// (KDE Plasma, the Agentic Desktop Layer, a VNC socket; no GPU needed) + Desktop { + #[command(subcommand)] + action: DesktopCmd, + }, /// Open a machine's screen on w3bs.org/fleet in your browser (uses your `w3bs login`) View { /// Machine name or id from `w3bs fleet ls` @@ -443,6 +451,36 @@ enum FleetCmd { }, } +#[derive(Subcommand, Clone)] +enum DesktopCmd { + /// Build (first time) and start the desktop, and share its screen with the fleet owner + Start { + /// Also let the owner use the mouse and keyboard from w3bs.org/fleet + #[arg(long)] + control: bool, + /// Screen size + #[arg(long, value_name = "WxH")] + geometry: Option, + /// Rebuild the image even if it is current + #[arg(long)] + rebuild: bool, + }, + /// Stop and remove the desktop (its home directory is kept) and stop sharing it + Stop, + /// Whether the desktop runs, and what it shares + Status, + /// The container's recent log, and where the session logs are + Logs, + /// Register an AI agent on the desktop and print its MCP setup (Claude Code) + Agent { + /// Agent name + name: String, + /// What it may do: observe (look only), broad (whole desktop) + #[arg(long, default_value = "broad", value_parser = ["observe", "broad"])] + preset: String, + }, +} + #[derive(Subcommand, Clone)] enum ScreenCmd { /// Share the screen: view only (input from the page is dropped) unless --control @@ -767,6 +805,13 @@ async fn run_fleet(cmd: FleetCmd) -> (&'static str, w3bs_core::Result match action { + DesktopCmd::Start { control, geometry, rebuild } => local("fleet.desktop.start", desktop::start(control, geometry.as_deref(), rebuild)), + DesktopCmd::Stop => local("fleet.desktop.stop", desktop::stop()), + DesktopCmd::Status => local("fleet.desktop.status", desktop::status()), + DesktopCmd::Logs => local("fleet.desktop.logs", desktop::logs()), + DesktopCmd::Agent { name, preset } => local("fleet.desktop.agent", desktop::agent(&name, &preset)), + }, FleetCmd::View { machine, no_open, a } => ("fleet.view", account("fleet.view", &a, |o| async move { fleet::view(&o, &machine, !no_open).await }).await), FleetCmd::Agent { config } => local("fleet.agent", fleet::agent(config.as_deref()).await), FleetCmd::Status { config } => local("fleet.status", fleet::status(config.as_deref())), diff --git a/package-manager/crates/w3bs-cli/src/render.rs b/package-manager/crates/w3bs-cli/src/render.rs index 2b025c1..aa4d5aa 100644 --- a/package-manager/crates/w3bs-cli/src/render.rs +++ b/package-manager/crates/w3bs-cli/src/render.rs @@ -111,6 +111,50 @@ pub fn human(r: &OperationResult) { } return; } + "fleet.desktop.start" => { + let d = &r.data; + println!("The Kubuntu desktop is running (container {}, {})", s(&d["container"]), s(&d["image"])); + println!("AI agents: desktop layer MCP at {} (register one: w3bs fleet desktop agent )", s(&d["mcp"])); + if let Some(url) = d["url"].as_str() { + let mode = if d["screen"]["mode"] == "control" { "view and control" } else { "view only" }; + println!("Watch it ({mode}): {url}"); + } + for n in d["notes"].as_array().into_iter().flatten() { + eprintln!("note: {}", s(n)); + } + return; + } + "fleet.desktop.stop" => { + let d = &r.data; + println!("{}", if d["removed"] == true { "Stopped the desktop (its home directory is kept)" } else { "No desktop was running" }); + if d["screen_off"] == true { + println!("Screen sharing is off"); + } + return; + } + "fleet.desktop.status" => { + let d = &r.data; + println!("Desktop: {}{}", s(&d["state"]), d["started_at"].as_str().map(|t| format!(" since {t}")).unwrap_or_default()); + if d["image"].is_string() && d["image"] != d["current_image"] { + println!("Image: {} (a newer one is built in: w3bs fleet desktop start --rebuild)", s(&d["image"])); + } + println!("VNC: {} ({})", s(&d["vnc"]), if d["vnc_ready"] == true { "ready" } else { "not ready" }); + println!("MCP: {}", s(&d["mcp"])); + println!("Shared: {}", if d["screen"].is_null() { "no".to_owned() } else { format!("{} from {}", s(&d["screen"]["mode"]), s(&d["screen"]["vnc"])) }); + println!("Session logs: {}", s(&d["logs"])); + return; + } + "fleet.desktop.logs" => { + print!("{}", r.data["log"].as_str().unwrap_or_default()); + eprintln!("session logs: {}", s(&r.data["session_logs"])); + return; + } + "fleet.desktop.agent" => { + let d = &r.data; + println!("Registered {} ({}) on the desktop. Add it to Claude Code:", s(&d["agent"]), s(&d["preset"])); + println!("{}", s(&d["claude_code"])); + return; + } "fleet.view" => { let d = &r.data; println!("{}", s(&d["url"])); diff --git a/package-manager/crates/w3bs-cli/src/screen.rs b/package-manager/crates/w3bs-cli/src/screen.rs index 57745e6..bce2c2b 100644 --- a/package-manager/crates/w3bs-cli/src/screen.rs +++ b/package-manager/crates/w3bs-cli/src/screen.rs @@ -4,7 +4,8 @@ //! Then `w3bs fleet agent` keeps one outbound WebSocket to the fleet server //! (`/api/fleet/listen`). When the owner opens the screen page, the server sends a session //! id; the agent dials `/api/fleet/screen/`, connects it to the local VNC server -//! (127.0.0.1:5900 unless `--vnc` says otherwise) and copies bytes both ways. Nothing on the +//! (127.0.0.1:5900 unless `--vnc` says otherwise; `unix:/path` for a Unix socket, which is how +//! `w3bs fleet desktop` shares its container) and copies bytes both ways. Nothing on the //! machine listens to the network. //! //! In `view` mode the agent follows the RFB stream and drops every message that could change @@ -60,8 +61,12 @@ impl Screen { } } -/// `host:port`, with the port required: VNC has no default the user should be guessing at. +/// `host:port` (the port required: VNC has no default the user should be guessing at), or +/// `unix:/absolute/path` for a Unix socket. pub fn check_addr(addr: &str) -> Result<(), String> { + if let Some(path) = addr.strip_prefix("unix:") { + return if path.starts_with('/') { Ok(()) } else { Err(format!("{addr:?}: a Unix socket needs an absolute path, e.g. unix:/run/vnc.sock")) }; + } let (host, port) = addr.rsplit_once(':').ok_or_else(|| format!("{addr:?} needs a port, e.g. {DEFAULT_VNC}"))?; if host.is_empty() || port.parse::().is_err() { return Err(format!("{addr:?} is not host:port, e.g. {DEFAULT_VNC}")); @@ -73,7 +78,7 @@ pub fn check_addr(addr: &str) -> Result<(), String> { static PROBE: Mutex> = Mutex::new(None); -/// Whether something accepts TCP on the VNC address. Checked at most every two minutes so +/// Whether something accepts connections on the VNC address. Checked at most every two minutes so /// the VNC server is not poked on every heartbeat; `forget_probe` forces the next check. pub fn reachable(addr: &str) -> bool { let mut cache = PROBE.lock().unwrap(); @@ -84,6 +89,12 @@ pub fn reachable(addr: &str) -> bool { return *ok; } use std::net::ToSocketAddrs; + #[cfg(unix)] + if let Some(path) = addr.strip_prefix("unix:") { + let ok = std::os::unix::net::UnixStream::connect(path).is_ok(); + *cache = Some((Instant::now(), addr.to_owned(), ok)); + return ok; + } let ok = addr.to_socket_addrs().ok().into_iter().flatten().any(|sa| std::net::TcpStream::connect_timeout(&sa, Duration::from_secs(1)).is_ok()); *cache = Some((Instant::now(), addr.to_owned(), ok)); ok @@ -397,15 +408,29 @@ pub async fn listen(origin: String, key: String, screen: Screen) { } } +trait Io: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send {} +impl Io for T {} + +/// A connection to the VNC server at `host:port` or `unix:/path`. +async fn connect_vnc(addr: &str) -> std::io::Result> { + #[cfg(unix)] + if let Some(path) = addr.strip_prefix("unix:") { + return Ok(Box::new(tokio::net::UnixStream::connect(path).await?)); + } + let tcp = tokio::net::TcpStream::connect(addr).await?; + let _ = tcp.set_nodelay(true); + Ok(Box::new(tcp)) +} + /// One viewer: the session WebSocket joined to a fresh connection to the VNC server. async fn session(origin: String, key: String, screen: Screen, id: String) { - let vnc = tokio::time::timeout(CONNECT_TIMEOUT, tokio::net::TcpStream::connect(&screen.vnc)).await; + let vnc = tokio::time::timeout(CONNECT_TIMEOUT, connect_vnc(&screen.vnc)).await; let mut ws = match dial(&origin, &format!("/api/fleet/screen/{id}"), &key).await { Ok(ws) => ws, Err(e) => return eprintln!("w3bs fleet agent: screen session {id}: {e:?}"), }; - let tcp = match vnc { - Ok(Ok(tcp)) => tcp, + let conn = match vnc { + Ok(Ok(conn)) => conn, failed => { forget_probe(); let why = match failed { @@ -419,8 +444,7 @@ async fn session(origin: String, key: String, screen: Screen, id: String) { return; } }; - let _ = tcp.set_nodelay(true); - let (mut vnc_rx, mut vnc_tx) = tcp.into_split(); + let (mut vnc_rx, mut vnc_tx) = tokio::io::split(conn); let (mut ws_tx, mut ws_rx) = ws.split(); let gate = Arc::new(Mutex::new(Gate::new(screen.mode == Mode::View))); let (fail_tx, mut fail_rx) = tokio::sync::mpsc::channel::(2); @@ -608,6 +632,8 @@ mod tests { assert!(check_addr("[::1]:5901").is_ok()); assert!(check_addr("localhost").is_err()); assert!(check_addr(":5900").is_err()); + assert!(check_addr("unix:/home/a/.local/share/w3bs/desktop/vnc/vnc.sock").is_ok()); + assert!(check_addr("unix:vnc.sock").is_err()); assert_eq!(ws_url("https://w3bs.org", "/api/fleet/listen"), "wss://w3bs.org/api/fleet/listen"); assert_eq!(ws_url("http://localhost:3000", "/x"), "ws://localhost:3000/x"); } diff --git a/src/fleet-routes.mjs b/src/fleet-routes.mjs index 724f07b..b4cf155 100644 --- a/src/fleet-routes.mjs +++ b/src/fleet-routes.mjs @@ -77,11 +77,12 @@ export function mountFleet(app, { store, origin, helpers }) { title: 'Fleet', active: '/fleet', scripts: '', - content: `
W3BS FLEET

Your servers.

Every machine that ran w3bs fleet join for @${e(user.username)}. Each one reports every 30 seconds.

Add a server

Makes a one-time join command, valid for an hour. Run it on the server as the user the agent should run as.

screen sharing)

From the terminal

w3bs login
+      content: `
W3BS FLEET

Your servers.

Every machine that ran w3bs fleet join for @${e(user.username)}. Each one reports every 30 seconds.

Add a server

Makes a one-time join command, valid for an hour. Run it on the server as the user the agent should run as.

From the terminal

w3bs login
 w3bs fleet ls
 w3bs fleet run <name> update
 w3bs fleet rm <name>
-w3bs fleet view <name>      # opens its screen here
`, +w3bs fleet view <name> # opens its screen here +w3bs fleet desktop start # on a server: a Kubuntu desktop for AI agents, shared here
`, }); });