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`) `/screen/ws` WebSocket relay (`docs/web-ui/agent.md::Per-agent endpoints`)
connects to the compositor at `127.0.0.1:<vnc_port>`. connects to the compositor at `127.0.0.1:<vnc_port>`.
- **Port allocation**: deterministic FNV-1a of the agent name - **Port allocation**: a **fixed** port (`hyperhive.gui.vncPort`,
(read from `/etc/hostname`, leading `h-` stripped) mapped into default 5900). No per-agent hashing: network isolation is
`[15900, 16799]`. Mirrors the agent web-UI port pattern from unconditional (each agent has its own netns — see
`docs/gotchas.md::Web UI ports collide on hash` — same FNV-1a `docs/network.md#container-isolation`), so the VNC port is
constant, different range. The compositor's startup script writes container-local and can't collide across agents. The harness learns
`/etc/hyperhive/gui.json = {"vnc_port":N,"auth":"none","wayland_display":"wayland-0"}` the port from the `HIVE_GUI_VNC_PORT` env var (set on the harness
so the harness reads the port at runtime; no nix-side / harness-side hash service when `gui.enable`) — no marker file, no runtime hash. (Unlike
duplication. 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 - **Fixed Wayland socket name (`--socket=wayland-0`)**: weston is
launched with `--socket=wayland-0` so the socket path is launched with `--socket=wayland-0` so the socket path is
deterministic. `harness-base.nix` exports `WAYLAND_DISPLAY=wayland-0` 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 variables (gated on `hyperhive.gui.enable`) so every systemd service
in the container inherits them. Without this, services starting in the container inherits them. Without this, services starting
Wayland clients could not find the compositor — libwayland falls 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. land on the right pixel regardless of CSS scale.
- `GET /screen/ws` — raw RFB byte relay: proxies WebSocket - `GET /screen/ws` — raw RFB byte relay: proxies WebSocket
frames to the weston VNC server at `127.0.0.1:<vnc_port>`. frames to the weston VNC server at `127.0.0.1:<vnc_port>`.
Transparent to any RFB variant. VNC port comes from Transparent to any RFB variant. VNC port comes from the
`/etc/hyperhive/gui.json` (written by the weston startup `HIVE_GUI_VNC_PORT` env var (a fixed port set on the harness
script in `weston-vnc.nix`). service when `hyperhive.gui.enable`; see `weston-vnc.nix`).
Bus events (new vocabulary on `/events/stream`): Bus events (new vocabulary on `/events/stream`):

View file

@ -57,8 +57,8 @@ struct AppState {
files: TurnFiles, files: TurnFiles,
/// Prevents `/api/compact` from racing with an in-flight normal turn. /// Prevents `/api/compact` from racing with an in-flight normal turn.
turn_lock: TurnLock, turn_lock: TurnLock,
/// VNC port read from `/etc/hyperhive/gui.json` at startup. /// VNC port from the `HIVE_GUI_VNC_PORT` env var at startup.
/// `None` when the file is absent (gui not enabled for this agent). /// `None` when unset (gui not enabled for this agent).
gui_vnc_port: Option<u16>, gui_vnc_port: Option<u16>,
} }
@ -83,7 +83,7 @@ pub async fn serve(
files: TurnFiles, files: TurnFiles,
turn_lock: TurnLock, turn_lock: TurnLock,
) -> Result<()> { ) -> 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") let static_dir: PathBuf = std::env::var_os("HIVE_STATIC_DIR")
.map(PathBuf::from) .map(PathBuf::from)
.context( .context(
@ -277,12 +277,14 @@ async fn serve_icon() -> impl IntoResponse {
([("content-type", "image/svg+xml")], body) ([("content-type", "image/svg+xml")], body)
} }
/// Read `/etc/hyperhive/gui.json` and extract the `vnc_port` field. /// The fixed VNC port weston bound, from the `HIVE_GUI_VNC_PORT` env var
/// Returns `None` if the file is absent or unparseable — GUI not enabled. /// the harness service sets when gui is enabled (see weston-vnc.nix).
fn read_gui_json() -> Option<u16> { /// `None` when unset (gui not enabled for this agent) or unparseable.
let text = std::fs::read_to_string("/etc/hyperhive/gui.json").ok()?; /// The port is a fixed, container-local value — no per-agent hashing, no
let val: serde_json::Value = serde_json::from_str(&text).ok()?; /// marker file — because network isolation is unconditional (each agent
val["vnc_port"].as_u64().and_then(|p| u16::try_from(p).ok()) /// 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 /// 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. # bind-mounts and gateway upstream config stay in sync.
HIVE_WEB_SOCKET = "/run/hive-agent/${userName}/web.sock"; 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 = { serviceConfig = {
ExecStart = if otel.enable then "${otelExecStart}" else "${pkgs.hyperhive}/bin/${binary} serve"; ExecStart = if otel.enable then "${otelExecStart}" else "${pkgs.hyperhive}/bin/${binary} serve";
# Pin the journal identity to the binary name. Without this, # Pin the journal identity to the binary name. Without this,

View file

@ -4,6 +4,27 @@
config, 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, # Optional Weston (Wayland compositor) with the VNC backend,
# surfaced as a per-agent `hyperhive.gui.enable` option. Imported # surfaced as a per-agent `hyperhive.gui.enable` option. Imported
@ -25,16 +46,49 @@
relay. Renders in software (pixman) no GPU, DRM, or VT relay. Renders in software (pixman) no GPU, DRM, or VT
access, so no extra container capabilities are needed. access, so no extra container capabilities are needed.
The VNC port is a deterministic FNV-1a hash of the agent name Weston binds a fixed VNC port (`hyperhive.gui.vncPort`) on the
mapped into `[15900, 16799]`, written to container's own loopback. Network isolation is unconditional
`/etc/hyperhive/gui.json` at service start so the harness can (each agent has its own netns), so a fixed port can't collide
relay connections without a separate config flag. The unit is across containers no per-agent hashing needed. The harness
`Type = "simple"` so a misconfigured weston degrades to a learns the port from the `HIVE_GUI_VNC_PORT` env var (set by the
restart loop instead of blocking `nixos-container update`. 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 { 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 # neatvnc ≥ 0.9 always calls the PAM auth callback for Apple-DH
# (type 30), regardless of weston.ini auth-method=none. # (type 30), regardless of weston.ini auth-method=none.
# pam_permit.so accepts the browser's empty Apple-DH credentials. # pam_permit.so accepts the browser's empty Apple-DH credentials.
@ -53,59 +107,32 @@
serviceConfig = { serviceConfig = {
Type = "simple"; Type = "simple";
StateDirectory = "weston"; StateDirectory = "weston";
Environment = "XDG_RUNTIME_DIR=/run/user/0"; # Run as the agent's own user; share a fixed runtime dir at
# Wrapper script: computes the deterministic VNC port, writes # /run/gui (RuntimeDirectory creates+chowns it). Preserve it
# /etc/hyperhive/gui.json for the harness, then execs weston. # across weston restarts so the wayland client sharing the
# `exec` keeps the PID stable so systemd tracks the weston # /run/gui/wayland-0 socket doesn't lose the dir under it. 0700
# process correctly under Type=simple. # because a wayland XDG_RUNTIME_DIR must not be group/world-accessible.
ExecStart = pkgs.writeShellScript "weston-vnc" '' User = userName;
mkdir -p /run/user/0 && chmod 700 /run/user/0 || true Group = userName;
RuntimeDirectory = "gui";
# --- Compute deterministic VNC port via FNV-1a --- RuntimeDirectoryMode = "0700";
# Agent name = container hostname with leading `h-` stripped. RuntimeDirectoryPreserve = "yes";
# Read from /etc/hostname (always present in NixOS containers) Environment = "XDG_RUNTIME_DIR=/run/gui";
# to avoid depending on `hostname` (lives in pkgs.inetutils, # Direct exec (no wrapper script): fixed `--port`, static config.
# not pkgs.coreutils). # `--socket=wayland-0` pins the compositor's Wayland socket name
RAW_HOST=$(${pkgs.coreutils}/bin/cat /etc/hostname) # (weston otherwise picks any free name like `wayland-1`), so the
AGENT_NAME=$(${pkgs.coreutils}/bin/printf '%s' "$RAW_HOST" \ # `WAYLAND_DISPLAY=wayland-0` globalEnvironment injection below
| ${pkgs.gnused}/bin/sed 's/^h-//') # reaches every wayland client in the container deterministically
hash=2166136261 # (fixes double-screen: VNC showing a blank weston desktop while a
for byte in $(${pkgs.coreutils}/bin/printf '%s' "$AGENT_NAME" \ # client renders on a different seat). `--disable-transport-layer-
| ${pkgs.coreutils}/bin/od -An -tu1 \ # security` skips the VeNCrypt TLS wrapper so plain auth types
| ${pkgs.coreutils}/bin/tr -s ' \n' ' '); do # (incl. Apple-DH type 30) are advertised directly.
[ -n "$byte" ] || continue ExecStart = ''
hash=$(( ((hash ^ byte) * 16777619) & 4294967295 )) ${pkgs.weston}/bin/weston \
done --config=${westonIni} \
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" \
--backend=vnc-backend.so \ --backend=vnc-backend.so \
--renderer=pixman \ --renderer=pixman \
--port="$VNC_PORT" \ --port=${toString config.hyperhive.gui.vncPort} \
--socket=wayland-0 \ --socket=wayland-0 \
--disable-transport-layer-security --disable-transport-layer-security
''; '';
@ -127,7 +154,7 @@
# shows a blank desktop. # shows a blank desktop.
systemd.globalEnvironment = { systemd.globalEnvironment = {
WAYLAND_DISPLAY = "wayland-0"; 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 # weston on the agent's interactive PATH so claude can run Wayland