{ pkgs, lib, config, ... }: let # GUI processes run as the agent's own non-root user — the same user # hive-agent 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 [output] name=VNC-1 mode=1280x720 ''; in { # Optional Weston (Wayland compositor) with the VNC backend, # surfaced as a per-agent `hyperhive.gui.enable` option. Imported # from ./default.nix so every sub-agent + the manager sees the # option; only those that flip it on get the service. # # Port allocation, weston bind-address quirk, PAM service name, the # Type=simple choice, idle-time=0: all in # docs/gotchas.md::Weston VNC compositor. # Harness-side WebSocket relay shape: docs/web-ui/agent.md::Per-agent # endpoints (`/screen` + `/screen/ws`). options.hyperhive.gui.enable = lib.mkOption { type = lib.types.bool; default = false; description = '' Run Weston with the VNC backend as a systemd service, for in-browser GUI access via the harness `/screen/ws` WebSocket relay. Renders in software (pixman) — no GPU, DRM, or VT access, so no extra container capabilities are needed. 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, the wayland client and the agent harness run as the agent's own non-root user (`hyperhive.user.name`), sharing one session: a fixed `XDG_RUNTIME_DIR=/run/gui`, one wayland display, and one D-Bus session bus at `/run/gui/bus` (gui-dbus.service), so GUI clients need no private `dbus-run-session`. ''; }; # 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. # Service name MUST be the literal `weston-remote-access` — that's # the string libweston passes to pam_start() in libweston/auth.c. security.pam.services."weston-remote-access".text = '' auth sufficient pam_permit.so account sufficient pam_permit.so session sufficient pam_permit.so ''; # One shared D-Bus *session* bus for the whole GUI session, bound at # /run/gui/bus and owned by the agent user. Wayland GUI clients # (chromium / electron via ozone) refuse to map a toplevel without a # reachable session bus ("Failed to connect to the bus" -> binds # xdg_wm_base then destroys it = invisible window, though CDP still # works). Running ONE persistent bus here -- instead of each client # wrapping itself in `dbus-run-session` (a private throwaway bus per # process) -- keeps weston, the agent harness and the GUI client in a # single session: one user, one XDG_RUNTIME_DIR, one wayland display, # one bus. The address is exported via globalEnvironment below so # every unit in the container inherits it. systemd.services.gui-dbus = { description = "Shared D-Bus session bus for the GUI session"; wantedBy = [ "multi-user.target" ]; before = [ "weston.service" ]; serviceConfig = { Type = "simple"; User = userName; Group = userName; # Share /run/gui with weston; ordered `before` weston so this # creates + chowns the dir first. Preserve across restarts so # weston + clients don't lose the dir holding their sockets. RuntimeDirectory = "gui"; RuntimeDirectoryMode = "0700"; RuntimeDirectoryPreserve = "yes"; Environment = "XDG_RUNTIME_DIR=/run/gui"; # dbus-daemon unlinks a stale path socket before binding, so a # restart re-binds /run/gui/bus cleanly. ExecStart = "${pkgs.dbus}/bin/dbus-daemon --session --nofork --nopidfile --address=unix:path=/run/gui/bus"; SyslogIdentifier = "gui-dbus"; Restart = "on-failure"; RestartSec = "2s"; }; }; systemd.services.weston = { description = "Weston Wayland compositor (VNC backend)"; after = [ "network.target" "gui-dbus.service" ]; wants = [ "gui-dbus.service" ]; wantedBy = [ "multi-user.target" ]; serviceConfig = { Type = "simple"; StateDirectory = "weston"; # 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=${toString config.hyperhive.gui.vncPort} \ --socket=wayland-0 \ --disable-transport-layer-security ''; # ExecStart is already a direct `weston` exec (basename is clean), but # pin the identity explicitly so it can't drift if a wrapper is ever # introduced (sweep per the systemd SyslogIdentifier convention). SyslogIdentifier = "weston"; Restart = "on-failure"; RestartSec = "5s"; }; }; # Expose the compositor's socket to every systemd service in the # container so Wayland clients (e.g. bitburner started via # `systemd.services.*` in agent.nix) can find the compositor # without per-service wiring. `systemd.globalEnvironment` is the # correct path — it sets DefaultEnvironment in systemd.conf, # reaching all units started by PID 1. `environment.variables` # goes to /etc/environment (PAM sessions only) and is NOT visible # to systemd service units. Without these vars a service starting # a Wayland client would either fail to connect (libwayland falls # back to a headless display) or open a second compositor — VNC # shows a blank desktop. DBUS_SESSION_BUS_ADDRESS points every unit # at the one shared session bus (gui-dbus.service above) so GUI # clients use it instead of spawning a private `dbus-run-session`. systemd.globalEnvironment = { WAYLAND_DISPLAY = "wayland-0"; XDG_RUNTIME_DIR = "/run/gui"; DBUS_SESSION_BUS_ADDRESS = "unix:path=/run/gui/bus"; }; # weston on the agent's interactive PATH so claude can run Wayland # clients / weston-info against the compositor. environment.systemPackages = [ pkgs.weston ]; }; }