feat(#1906): non-root weston gui on a fixed vnc port

This commit is contained in:
damocles 2026-06-23 22:26:08 +02:00 committed by mara
commit 3d2e0ef561
5 changed files with 127 additions and 80 deletions

View file

@ -253,18 +253,27 @@ compositor with the VNC backend, surfaced as
`/screen/ws` WebSocket relay (`docs/web-ui/agent.md::Per-agent endpoints`)
connects to the compositor at `127.0.0.1:<vnc_port>`.
- **Port allocation**: deterministic FNV-1a of the agent name
(read from `/etc/hostname`, leading `h-` stripped) mapped into
`[15900, 16799]`. Mirrors the agent web-UI port pattern from
`docs/gotchas.md::Web UI ports collide on hash` — same FNV-1a
constant, different range. The compositor's startup script writes
`/etc/hyperhive/gui.json = {"vnc_port":N,"auth":"none","wayland_display":"wayland-0"}`
so the harness reads the port at runtime; no nix-side / harness-side hash
duplication.
- **Port allocation**: a **fixed** port (`hyperhive.gui.vncPort`,
default 5900). No per-agent hashing: network isolation is
unconditional (each agent has its own netns — see
`docs/network.md#container-isolation`), so the VNC port is
container-local and can't collide across agents. The harness learns
the port from the `HIVE_GUI_VNC_PORT` env var (set on the harness
service when `gui.enable`) — no marker file, no runtime hash. (Unlike
the agent **web-UI** port, which is still an FNV-1a hash because those
listen on the shared host stack — see `Web UI ports collide on hash`.)
- **Non-root, shared user session**: weston runs as the agent's own
user (`hyperhive.user.name`, the same user hive-ag3nt runs as), not
root, so the GUI and the agent share one session. The runtime dir is a
fixed `/run/gui` (systemd `RuntimeDirectory=gui`, `0700`,
`RuntimeDirectoryPreserve=yes` so it survives weston restarts for the
wayland client sharing the `/run/gui/wayland-0` socket). Wayland
clients in the agent's config (e.g. bitburner electron) must run as the
same user with `XDG_RUNTIME_DIR=/run/gui`.
- **Fixed Wayland socket name (`--socket=wayland-0`)**: weston is
launched with `--socket=wayland-0` so the socket path is
deterministic. `harness-base.nix` exports `WAYLAND_DISPLAY=wayland-0`
and `XDG_RUNTIME_DIR=/run/user/0` as global system environment
and `XDG_RUNTIME_DIR=/run/gui` as global system environment
variables (gated on `hyperhive.gui.enable`) so every systemd service
in the container inherits them. Without this, services starting
Wayland clients could not find the compositor — libwayland falls

View file

@ -355,9 +355,9 @@ shaped).
land on the right pixel regardless of CSS scale.
- `GET /screen/ws` — raw RFB byte relay: proxies WebSocket
frames to the weston VNC server at `127.0.0.1:<vnc_port>`.
Transparent to any RFB variant. VNC port comes from
`/etc/hyperhive/gui.json` (written by the weston startup
script in `weston-vnc.nix`).
Transparent to any RFB variant. VNC port comes from the
`HIVE_GUI_VNC_PORT` env var (a fixed port set on the harness
service when `hyperhive.gui.enable`; see `weston-vnc.nix`).
Bus events (new vocabulary on `/events/stream`):

View file

@ -57,8 +57,8 @@ 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).
/// VNC port from the `HIVE_GUI_VNC_PORT` env var at startup.
/// `None` when unset (gui not enabled for this agent).
gui_vnc_port: Option<u16>,
}
@ -83,7 +83,7 @@ pub async fn serve(
files: TurnFiles,
turn_lock: TurnLock,
) -> Result<()> {
let gui_vnc_port = read_gui_json();
let gui_vnc_port = read_gui_vnc_port();
let static_dir: PathBuf = std::env::var_os("HIVE_STATIC_DIR")
.map(PathBuf::from)
.context(
@ -277,12 +277,14 @@ async fn serve_icon() -> impl IntoResponse {
([("content-type", "image/svg+xml")], body)
}
/// 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<u16> {
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())
/// The fixed VNC port weston bound, from the `HIVE_GUI_VNC_PORT` env var
/// the harness service sets when gui is enabled (see weston-vnc.nix).
/// `None` when unset (gui not enabled for this agent) or unparseable.
/// The port is a fixed, container-local value — no per-agent hashing, no
/// marker file — because network isolation is unconditional (each agent
/// has its own netns, so the port can't collide across containers).
fn read_gui_vnc_port() -> Option<u16> {
std::env::var("HIVE_GUI_VNC_PORT").ok()?.parse().ok()
}
/// WebSocket handler: upgrade then pump bytes between the WS client and

View file

@ -1772,7 +1772,16 @@ in
# bind-mounts and gateway upstream config stay in sync.
HIVE_WEB_SOCKET = "/run/hive-agent/${userName}/web.sock";
}
// otelEnv;
// otelEnv
// lib.optionalAttrs config.hyperhive.gui.enable {
# Tells the harness which fixed VNC port weston bound, and (by
# its presence) that gui is enabled — the harness `/screen/ws`
# relay reads this instead of a runtime marker file. The port is
# container-local + fixed (network isolation is unconditional),
# so the same value for every gui agent is fine. See
# nix/templates/weston-vnc.nix::hyperhive.gui.vncPort.
HIVE_GUI_VNC_PORT = toString config.hyperhive.gui.vncPort;
};
serviceConfig = {
ExecStart = if otel.enable then "${otelExecStart}" else "${pkgs.hyperhive}/bin/${binary} serve";
# Pin the journal identity to the binary name. Without this,

View file

@ -4,6 +4,27 @@
config,
...
}:
let
# GUI processes run as the agent's own non-root user — the same user
# hive-ag3nt runs as (declared + home-chowned by harness-base.nix) — so
# weston, the wayland client, and the agent share one user session.
# `hyperhive.user.name` is set per-agent by the meta-flake renderer.
userName = config.hyperhive.user.name;
# Static weston config. `[core] idle-time=0` disables the 300s idle /
# lock screen; `[vnc] auth-method=none` + the `--disable-transport-
# layer-security` flag below advertise plain auth types directly. A
# store file (not a runtime mktemp) so weston's ExecStart is a direct
# exec — no wrapper script, which keeps the journal SyslogIdentifier
# clean (`weston`, not a store-path basename).
westonIni = pkgs.writeText "weston.ini" ''
[core]
idle-time=0
[vnc]
auth-method=none
'';
in
{
# Optional Weston (Wayland compositor) with the VNC backend,
# surfaced as a per-agent `hyperhive.gui.enable` option. Imported
@ -25,16 +46,49 @@
relay. Renders in software (pixman) no GPU, DRM, or VT
access, so no extra container capabilities are needed.
The VNC port is a deterministic FNV-1a hash of the agent name
mapped into `[15900, 16799]`, written to
`/etc/hyperhive/gui.json` at service start so the harness can
relay connections without a separate config flag. The unit is
`Type = "simple"` so a misconfigured weston degrades to a
restart loop instead of blocking `nixos-container update`.
Weston binds a fixed VNC port (`hyperhive.gui.vncPort`) on the
container's own loopback. Network isolation is unconditional
(each agent has its own netns), so a fixed port can't collide
across containers no per-agent hashing needed. The harness
learns the port from the `HIVE_GUI_VNC_PORT` env var (set by the
harness service when gui is enabled). The unit is
`Type = "simple"` so a misconfigured weston degrades to a restart
loop instead of blocking `nixos-container update`.
Weston and the wayland client run as the agent's own non-root
user (`hyperhive.user.name`), sharing one user session with a
fixed `XDG_RUNTIME_DIR=/run/gui`.
'';
};
# Fixed VNC port weston binds inside the container. Safe to be the
# same for every agent because network isolation is unconditional
# (private netns per container — see hive-network.nix), so the port
# is container-local and can't collide. Internal: the harness reads
# the value via the `HIVE_GUI_VNC_PORT` env var the harness service
# injects from this option, not directly.
options.hyperhive.gui.vncPort = lib.mkOption {
type = lib.types.port;
default = 5900;
internal = true;
description = ''
VNC port weston binds inside the container (default 5900, the
standard VNC port). Container-local, so the same value for every
agent is fine. Surfaced to the harness as `HIVE_GUI_VNC_PORT`.
'';
};
config = lib.mkIf config.hyperhive.gui.enable {
# The GUI must run non-root: weston + the wayland client share the
# agent's own user session. `user.name` is the agent name for every
# spawned agent; only a misconfigured root-named agent would trip this.
assertions = [
{
assertion = userName != "root";
message = "hyperhive.gui.enable requires a non-root hyperhive.user.name (the GUI runs as that user).";
}
];
# neatvnc ≥ 0.9 always calls the PAM auth callback for Apple-DH
# (type 30), regardless of weston.ini auth-method=none.
# pam_permit.so accepts the browser's empty Apple-DH credentials.
@ -53,59 +107,32 @@
serviceConfig = {
Type = "simple";
StateDirectory = "weston";
Environment = "XDG_RUNTIME_DIR=/run/user/0";
# Wrapper script: computes the deterministic VNC port, writes
# /etc/hyperhive/gui.json for the harness, then execs weston.
# `exec` keeps the PID stable so systemd tracks the weston
# process correctly under Type=simple.
ExecStart = pkgs.writeShellScript "weston-vnc" ''
mkdir -p /run/user/0 && chmod 700 /run/user/0 || true
# --- Compute deterministic VNC port via FNV-1a ---
# Agent name = container hostname with leading `h-` stripped.
# Read from /etc/hostname (always present in NixOS containers)
# to avoid depending on `hostname` (lives in pkgs.inetutils,
# not pkgs.coreutils).
RAW_HOST=$(${pkgs.coreutils}/bin/cat /etc/hostname)
AGENT_NAME=$(${pkgs.coreutils}/bin/printf '%s' "$RAW_HOST" \
| ${pkgs.gnused}/bin/sed 's/^h-//')
hash=2166136261
for byte in $(${pkgs.coreutils}/bin/printf '%s' "$AGENT_NAME" \
| ${pkgs.coreutils}/bin/od -An -tu1 \
| ${pkgs.coreutils}/bin/tr -s ' \n' ' '); do
[ -n "$byte" ] || continue
hash=$(( ((hash ^ byte) * 16777619) & 4294967295 ))
done
VNC_PORT=$((15900 + hash % 900))
# Marker file the harness reads at startup. Also records the
# fixed Wayland socket name (`wayland-0`) so other tooling can
# read it without inspecting the env-var injection below.
${pkgs.coreutils}/bin/mkdir -p /etc/hyperhive
${pkgs.coreutils}/bin/printf '{"vnc_port":%d,"auth":"none","wayland_display":"wayland-0"}\n' \
"$VNC_PORT" > /etc/hyperhive/gui.json || true
# --disable-transport-layer-security: skips the VeNCrypt TLS
# wrapper so plain auth types (incl. Apple-DH type 30) are
# advertised directly. [core] idle-time=0 disables the
# compositor's 300s idle/lock screen.
WESTON_INI=$(${pkgs.coreutils}/bin/mktemp /tmp/weston-XXXXXX.ini)
${pkgs.coreutils}/bin/printf '[core]\nidle-time=0\n\n[vnc]\nauth-method=none\n' > "$WESTON_INI"
# --socket=wayland-0: pin the compositor's Wayland socket name
# to `wayland-0` (weston default is to pick any free name such
# as `wayland-1`). Pinning lets the WAYLAND_DISPLAY=wayland-0
# global env injection below (see systemd.globalEnvironment) take
# effect unconditionally — any Wayland client launched by any
# systemd service in this container automatically connects to
# this compositor instead of failing or starting a second
# isolated display (fixes double-screen: VNC showing blank
# weston desktop while services render on a different seat).
exec ${pkgs.weston}/bin/weston \
--config="$WESTON_INI" \
# Run as the agent's own user; share a fixed runtime dir at
# /run/gui (RuntimeDirectory creates+chowns it). Preserve it
# across weston restarts so the wayland client sharing the
# /run/gui/wayland-0 socket doesn't lose the dir under it. 0700
# because a wayland XDG_RUNTIME_DIR must not be group/world-accessible.
User = userName;
Group = userName;
RuntimeDirectory = "gui";
RuntimeDirectoryMode = "0700";
RuntimeDirectoryPreserve = "yes";
Environment = "XDG_RUNTIME_DIR=/run/gui";
# Direct exec (no wrapper script): fixed `--port`, static config.
# `--socket=wayland-0` pins the compositor's Wayland socket name
# (weston otherwise picks any free name like `wayland-1`), so the
# `WAYLAND_DISPLAY=wayland-0` globalEnvironment injection below
# reaches every wayland client in the container deterministically
# (fixes double-screen: VNC showing a blank weston desktop while a
# client renders on a different seat). `--disable-transport-layer-
# security` skips the VeNCrypt TLS wrapper so plain auth types
# (incl. Apple-DH type 30) are advertised directly.
ExecStart = ''
${pkgs.weston}/bin/weston \
--config=${westonIni} \
--backend=vnc-backend.so \
--renderer=pixman \
--port="$VNC_PORT" \
--port=${toString config.hyperhive.gui.vncPort} \
--socket=wayland-0 \
--disable-transport-layer-security
'';
@ -127,7 +154,7 @@
# shows a blank desktop.
systemd.globalEnvironment = {
WAYLAND_DISPLAY = "wayland-0";
XDG_RUNTIME_DIR = "/run/user/0";
XDG_RUNTIME_DIR = "/run/gui";
};
# weston on the agent's interactive PATH so claude can run Wayland