hyperhive/nix/agent-modules/weston-vnc.nix

216 lines
9.5 KiB
Nix

{
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 ];
};
}