diff --git a/docs/gotchas.md b/docs/gotchas.md index 79dd351d..45271765 100644 --- a/docs/gotchas.md +++ b/docs/gotchas.md @@ -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:`. -- **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 diff --git a/docs/web-ui/agent.md b/docs/web-ui/agent.md index 4ad53c60..e63ba865 100644 --- a/docs/web-ui/agent.md +++ b/docs/web-ui/agent.md @@ -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:`. - 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`): diff --git a/hive-ag3nt/src/web_ui.rs b/hive-ag3nt/src/web_ui.rs index f2835c1d..2c253193 100644 --- a/hive-ag3nt/src/web_ui.rs +++ b/hive-ag3nt/src/web_ui.rs @@ -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, } @@ -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 { - 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 { + std::env::var("HIVE_GUI_VNC_PORT").ok()?.parse().ok() } /// WebSocket handler: upgrade then pump bytes between the WS client and diff --git a/nix/templates/harness-base.nix b/nix/templates/harness-base.nix index 58d2b15f..355b5f1f 100644 --- a/nix/templates/harness-base.nix +++ b/nix/templates/harness-base.nix @@ -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, diff --git a/nix/templates/weston-vnc.nix b/nix/templates/weston-vnc.nix index 601d4c53..1d9a7c57 100644 --- a/nix/templates/weston-vnc.nix +++ b/nix/templates/weston-vnc.nix @@ -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