# Per-agent web UI static tree: the shipped frontend dist, the # operator-extendable `extraFiles` overlay, and the merged tree the # harness serves via `HIVE_STATIC_DIR`. { pkgs, lib, config, ... }: { options.hyperhive.frontend.dist = lib.mkOption { type = lib.types.package; default = config.hyperhive.packages.frontend; defaultText = lib.literalMD "`hyperhive.packages.frontend` (the flake's frontend dist)"; description = '' The shipped frontend dist (built by `nix/packages/frontend.nix`). Output layout: `dashboard/` (used by hive-c0re on the host) and `agent/` (used here, layered with `extraFiles` below at activation time). Override to ship a fully custom per-agent SPA; the JSON contract (`/api/state`, `/events/stream`, the action endpoints) is the source of truth for any replacement. ''; }; options.hyperhive.frontend.mergedDist = lib.mkOption { type = lib.types.package; readOnly = true; description = '' Computed: the merged static tree consumed by the harness via `HIVE_STATIC_DIR`. Composed at evaluation time by copying `hyperhive.frontend.dist`'s `agent/` subdir as the base, then layering each `extraFiles` entry on top. Read-only — do not set directly. ''; }; options.hyperhive.frontend.extraFiles = lib.mkOption { type = lib.types.attrsOf ( lib.types.submodule ( { name, ... }: { options = { source = lib.mkOption { type = lib.types.path; description = '' Source file or directory to layer over the default agent dist. A path (relative to `agent.nix` or absolute) — nix copies its contents into the merged static tree. ''; }; target = lib.mkOption { # First char must be alphanumeric/underscore (rules out # leading `/`, leading `.`, leading `-`); inner chars # include `.` and `/` so nested layouts like # `"games/bitburner"` work. This is the shape check — # the `..`-segment traversal check is the assertion in # `config.assertions` below (regex alone can't reject # mid-path `..` segments without lookahead, which nix # POSIX regex doesn't support). type = lib.types.strMatching "^[A-Za-z0-9_][A-Za-z0-9_./-]*$"; default = name; defaultText = lib.literalMD "the attribute name"; description = '' Destination path within the merged static tree, used as both the served URL prefix (`//...`) and the on-disk layout in the merged derivation. Defaults to the attribute name. Use forward slashes for nested layouts (e.g. `"games/bitburner"`). Constrained shape: must start with an alphanumeric or `_`, and only contain alphanumerics, `_`, `.`, `/`, `-`. `..` segments are separately rejected at config eval time. ''; }; }; } ) ); default = { }; example = lib.literalExpression '' { bitburner = { source = ./bitburner-dist; # served at GET /bitburner/... }; } ''; description = '' Per-agent additions layered on top of the default frontend dist. Each entry copies its `source` into the served static tree under `target`. Useful for shipping a self-contained agent-specific surface alongside the standard agent UI (e.g. the bitburner agent's game page at `/bitburner/`). The default agent UI remains served at `/`; entries here only add new routes and never replace the default. Overwrite semantics are **hard-fail**: if `target` collides with an existing file or directory in the default dist (or with a prior entry's target), the `mergedDist` build aborts with `refusing to overwrite existing path '' in the default dist`. To override a default file, fork the dist via `hyperhive.frontend.dist` instead — `extraFiles` is for pure additions. `target` must be a relative path inside the static dir. An assertion rejects leading `/` and `..` segments at config eval time (string-concat-into-paths safety, even though agent.nix goes through operator review before deploy). ''; }; config = { assertions = [ # hyperhive.frontend.extraFiles[*].target is concatenated into # $out during the mergedDist build. The option's strMatching # type already rejects leading `/`, leading `.`, and the # weirder characters; this assertion catches mid-path `..` # segments (e.g. `foo/../etc/passwd`) that the type's regex # can't easily express without lookahead. agent.nix is # operator-reviewed, so this is belt-and-braces — but it's the # kind of mistake that's easy to make and hard to spot. { assertion = lib.all (entry: !(builtins.any (seg: seg == "..") (lib.splitString "/" entry.target))) ( lib.attrValues config.hyperhive.frontend.extraFiles ); message = '' hyperhive.frontend.extraFiles: `target` must not contain `..` path segments. ''; } ]; # Merged frontend static tree. Base = `${frontend.dist}/agent/`, # then each `extraFiles` entry is laid on top at its `target` # path. The runCommand derivation aborts on overwrite so a # filename collision with the default dist surfaces as a build # failure rather than a silent override (operator gets a clear # nix error rather than a confusing 404 / silent dist swap). hyperhive.frontend.mergedDist = pkgs.runCommand "hyperhive-agent-frontend-merged" { } ( '' mkdir -p $out cp -r ${config.hyperhive.frontend.dist}/agent/. $out/ chmod -R u+w $out '' + lib.concatMapStrings (entry: '' mkdir -p $(dirname $out/${entry.target}) if [ -e $out/${entry.target} ]; then echo "hyperhive.frontend.extraFiles: refusing to overwrite existing path '${entry.target}' in the default dist" >&2 exit 1 fi cp -r ${entry.source} $out/${entry.target} '') (lib.attrValues config.hyperhive.frontend.extraFiles) ); }; }