From 2027e9443200324524ac403b3e1cd505646dceb8 Mon Sep 17 00:00:00 2001 From: iris Date: Wed, 20 May 2026 14:24:05 +0200 Subject: [PATCH] harness: add /screen page and /screen/ws WebSocket VNC relay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reads /etc/hyperhive/gui.json at startup to get the VNC port written by the weston-vnc ExecStart script (issue #50). Adds: - gui_vnc_port: Option on AppState - gui_enabled: bool on StateSnapshot (for issue #52 screen link) - GET /screen: serves a minimal RFB-over-WebSocket viewer (screen.html) - GET /screen/ws: upgrades to WebSocket and byte-pumps to 127.0.0.1: The relay is a pure two-task byte pump (WS→TCP and TCP→WS), transparent to any RFB variant including VeNCrypt. Returns 404 when gui is not enabled. screen.html is a self-contained RFB client: handshake, FramebufferUpdate (Raw encoding), pointer and keyboard forwarding — enough to display the desktop and interact with it. noVNC assets (issue #52) replace this. Closes #51 --- Cargo.lock | 206 ++++++++++++++++++++ Cargo.toml | 2 +- hive-ag3nt/Cargo.toml | 1 + hive-ag3nt/assets/screen.html | 345 ++++++++++++++++++++++++++++++++++ hive-ag3nt/src/web_ui.rs | 98 ++++++++++ 5 files changed, 651 insertions(+), 1 deletion(-) create mode 100644 hive-ag3nt/assets/screen.html diff --git a/Cargo.lock b/Cargo.lock index 006f8fb..c161e87 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -106,6 +106,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ "axum-core", + "base64", "bytes", "form_urlencoded", "futures-util", @@ -124,8 +125,10 @@ dependencies = [ "serde_json", "serde_path_to_error", "serde_urlencoded", + "sha1", "sync_wrapper", "tokio", + "tokio-tungstenite", "tower", "tower-layer", "tower-service", @@ -151,12 +154,27 @@ dependencies = [ "tracing", ] +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bitflags" version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -249,6 +267,25 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "darling" version = "0.23.0" @@ -283,6 +320,22 @@ dependencies = [ "syn", ] +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "dyn-clone" version = "1.0.20" @@ -420,6 +473,28 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -451,6 +526,7 @@ dependencies = [ "anyhow", "axum", "clap", + "futures-util", "hive-fr0nt", "hive-sh4re", "rmcp", @@ -749,6 +825,15 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -767,6 +852,41 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom", +] + [[package]] name = "ref-cast" version = "1.0.25" @@ -967,6 +1087,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -1105,6 +1236,18 @@ dependencies = [ "tokio-util", ] +[[package]] +name = "tokio-tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -1208,6 +1351,28 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand", + "sha1", + "thiserror", +] + +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -1232,12 +1397,27 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.121" @@ -1351,6 +1531,32 @@ dependencies = [ "windows-link", ] +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/Cargo.toml b/Cargo.toml index 4c2bbbb..0da23cd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,7 +16,7 @@ must_use_candidate = "allow" [workspace.dependencies] anyhow = "1" -axum = "0.8" +axum = { version = "0.8", features = ["ws"] } clap = { version = "4", features = ["derive"] } hive-fr0nt = { path = "hive-fr0nt" } hive-sh4re = { path = "hive-sh4re" } diff --git a/hive-ag3nt/Cargo.toml b/hive-ag3nt/Cargo.toml index 5df9a20..caee017 100644 --- a/hive-ag3nt/Cargo.toml +++ b/hive-ag3nt/Cargo.toml @@ -9,6 +9,7 @@ workspace = true [dependencies] anyhow.workspace = true axum.workspace = true +futures-util = "0.3" clap.workspace = true hive-fr0nt.workspace = true hive-sh4re.workspace = true diff --git a/hive-ag3nt/assets/screen.html b/hive-ag3nt/assets/screen.html new file mode 100644 index 0000000..810759c --- /dev/null +++ b/hive-ag3nt/assets/screen.html @@ -0,0 +1,345 @@ + + + + + +screen + + + +
+ 🖥 screen + ← agent + connecting… +
+
+
+ + + + diff --git a/hive-ag3nt/src/web_ui.rs b/hive-ag3nt/src/web_ui.rs index 4e35833..8c324c9 100644 --- a/hive-ag3nt/src/web_ui.rs +++ b/hive-ag3nt/src/web_ui.rs @@ -22,6 +22,7 @@ use axum::{ routing::{get, post}, }; use serde::{Deserialize, Serialize}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio_stream::{Stream, StreamExt, wrappers::BroadcastStream}; use crate::client; @@ -56,6 +57,9 @@ struct AppState { files: TurnFiles, /// Prevents `/api/compact` from racing with an in-flight normal turn. turn_lock: TurnLock, + /// VNC port read from `/etc/hyperhive/gui.json` at startup. + /// `None` when the file is absent (gui not enabled for this agent). + gui_vnc_port: Option, } impl AppState { @@ -80,6 +84,7 @@ pub async fn serve( files: TurnFiles, turn_lock: TurnLock, ) -> Result<()> { + let gui_vnc_port = read_gui_json(); let state = AppState { label, login, @@ -88,6 +93,7 @@ pub async fn serve( socket, files, turn_lock, + gui_vnc_port, }; let app = Router::new() .route("/", get(serve_index)) @@ -110,6 +116,8 @@ pub async fn serve( .route("/stats", get(serve_stats)) .route("/static/stats.js", get(serve_stats_js)) .route("/api/stats", get(api_stats)) + .route("/screen", get(serve_screen)) + .route("/screen/ws", get(screen_ws)) .with_state(state); let addr = SocketAddr::from(([0, 0, 0, 0], port)); let listener = bind_with_retry(addr, "web UI").await?; @@ -215,6 +223,91 @@ async fn serve_stats_js() -> impl IntoResponse { ) } +async fn serve_screen() -> impl IntoResponse { + ( + [("content-type", "text/html; charset=utf-8")], + include_str!("../assets/screen.html"), + ) +} + +/// Read `/etc/hyperhive/gui.json` and extract the `vnc_port` field. +/// Returns `None` if the file is absent or unparseable — GUI not enabled. +fn read_gui_json() -> Option { + let text = std::fs::read_to_string("/etc/hyperhive/gui.json").ok()?; + let val: serde_json::Value = serde_json::from_str(&text).ok()?; + val["vnc_port"].as_u64().and_then(|p| u16::try_from(p).ok()) +} + +/// WebSocket handler: upgrade then pump bytes between the WS client and +/// the VNC server on `127.0.0.1:`. Returns 404 when gui is not +/// enabled for this agent. +async fn screen_ws( + ws: axum::extract::ws::WebSocketUpgrade, + State(state): State, +) -> Response { + let Some(vnc_port) = state.gui_vnc_port else { + return (StatusCode::NOT_FOUND, "gui not enabled for this agent").into_response(); + }; + ws.on_upgrade(move |socket| relay_ws_vnc(socket, vnc_port)) +} + +/// Pure byte pump: forwards raw bytes between the WebSocket client and +/// the VNC TCP stream. Transparent to any RFB variant (plain, VeNCrypt). +async fn relay_ws_vnc(socket: axum::extract::ws::WebSocket, vnc_port: u16) { + // Import futures traits locally so they don't conflict with + // tokio_stream::StreamExt used at module scope. + use axum::extract::ws::Message; + use futures_util::{SinkExt, StreamExt as _}; + + let addr = format!("127.0.0.1:{vnc_port}"); + let Ok(tcp) = tokio::net::TcpStream::connect(&addr).await else { + tracing::warn!(%addr, "screen/ws: could not connect to VNC server"); + return; + }; + let (mut tcp_rx, mut tcp_tx) = tcp.into_split(); + let (mut ws_tx, mut ws_rx) = socket.split(); + + // WS → TCP + let ws_to_tcp = tokio::spawn(async move { + while let Some(Ok(msg)) = futures_util::StreamExt::next(&mut ws_rx).await { + match msg { + Message::Binary(data) => { + if tcp_tx.write_all(&data).await.is_err() { + break; + } + } + Message::Close(_) => break, + _ => {} // ping/pong/text: ignore + } + } + }); + + // TCP → WS + let tcp_to_ws = tokio::spawn(async move { + let mut buf = vec![0u8; 8192]; + loop { + match tcp_rx.read(&mut buf).await { + Ok(0) | Err(_) => break, + Ok(n) => { + if ws_tx + .send(Message::Binary(buf[..n].to_vec().into())) + .await + .is_err() + { + break; + } + } + } + } + }); + + // Wait for either direction to close, then let both tasks drop. + tokio::select! { + _ = ws_to_tcp => {} + _ = tcp_to_ws => {} + } +} + #[derive(Deserialize)] struct StatsQuery { window: Option, @@ -271,6 +364,10 @@ struct StateSnapshot { /// Cumulative token usage across the most recent turn's inferences /// (cost signal). `null` until the first turn finishes. cost_usage: Option, + /// Whether the weston VNC compositor is configured for this agent + /// (i.e. `/etc/hyperhive/gui.json` was present at harness startup). + /// When true, the UI may render a `🖥 screen` link to `/screen`. + gui_enabled: bool, } #[derive(Serialize)] @@ -367,6 +464,7 @@ async fn api_state(State(state): State) -> axum::Json { model, ctx_usage, cost_usage, + gui_enabled: state.gui_vnc_port.is_some(), }) }