diff --git a/README.md b/README.md index 1247d78b..a32985d9 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,7 @@ This gives you nearly **1:1 parity with Hermes CLI from a convenient web UI** wh - [Quick start](#quick-start) — clone + `bootstrap.py` / `start.sh` / `ctl.sh` - [Features](#features) — chat, sessions, workspace, voice, profiles, security, themes, panels, mobile - [Configuration & access](#configuration--access) — auto-discovery, overrides, remote/Tailscale/phone, manual launch +- [Nix flake/module](#nix-flake-and-nixos-module) — declarative install and service - [Docker](#docker) — single- and multi-container deploys - [Running tests](#running-tests) - [Architecture](#architecture) — backend/frontend layout, state dir @@ -378,6 +379,63 @@ Extension deployments can inspect sanitized, authenticated diagnostics at `GET / --- +### Nix flake and NixOS module + +Hermes WebUI has a Nix flake package and a NixOS service module so you can run it declaratively. + +Install the latest package with: + +```bash +nix shell github:nesquena/hermes-webui#default +``` + +Use this flake input in your system configuration: + +```nix +inputs.hermes-webui.url = "github:nesquena/hermes-webui"; +``` + +Then add the module and configure it in `nixosModules`: + +```nix +{ + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + hermes-webui.url = "github:nesquena/hermes-webui"; + }; + + outputs = { self, nixpkgs, hermes-webui, ... }: { + nixosConfigurations. = nixpkgs.lib.nixosSystem { + modules = [ + hermes-webui.nixosModules.default + ]; + }; + }; +} +``` + +Service example: + +```nix +services.hermes-webui = { + enable = true; + host = "0.0.0.0"; + port = 8787; + stateDir = "/var/lib/hermes-webui"; + agent.dir = "/var/lib/hermes/hermes-agent"; + environmentFiles = [ "/run/secrets/hermes-webui.env" ]; +}; +``` + +Keep the module default host at `127.0.0.1`. Set `host = "0.0.0.0"` only when you also provide auth, for example `HERMES_WEBUI_PASSWORD` via `environmentFiles`. + +You can also set `agent.package` instead of `agent.dir` when you are using a compatible Hermes Agent package layout. The module derives `HERMES_WEBUI_AGENT_DIR` from `/share/hermes-agent` and sets `HERMES_WEBUI_PYTHON` from the package's `passthru.hermesVenv` interpreter so bootstrap can use agent dependencies without creating a local `.venv`. + +The module maps directly onto existing WebUI environment variables, including: +`HERMES_WEBUI_HOST`, `HERMES_WEBUI_PORT`, `HERMES_WEBUI_STATE_DIR`, `HERMES_HOME`, `HERMES_WEBUI_AGENT_DIR`, and `HERMES_WEBUI_PYTHON`. + +Set `environmentFiles` for secrets like API keys. Protected WebUI runtime keys from the module are rejected there, so keep host, port, state, and agent wiring in the module options. Keep reverse proxy and TLS configuration in your surrounding deployment module because those details are deployment-specific. + ### Remote access (SSH tunnel, Tailscale, phone) The server binds to `127.0.0.1` by default. To reach it from another machine use an SSH tunnel (`ssh -N -L 8787:127.0.0.1:8787 user@host`, which `start.sh` prints for you over SSH), or join your server and phone to a [Tailscale](https://tailscale.com) network and browse to `http://:8787` with `HERMES_WEBUI_HOST=0.0.0.0` + `HERMES_WEBUI_PASSWORD` set. Full walkthrough (incl. a community ARM64-Android field report): [`docs/remote-access.md`](docs/remote-access.md). diff --git a/flake.nix b/flake.nix new file mode 100644 index 00000000..1af10634 --- /dev/null +++ b/flake.nix @@ -0,0 +1,88 @@ +{ + description = "Hermes Web UI"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + }; + + outputs = { self, nixpkgs, ... }: + let + supportedSystems = [ + "x86_64-linux" + "aarch64-linux" + "x86_64-darwin" + "aarch64-darwin" + ]; + linuxSystems = [ "x86_64-linux" "aarch64-linux" ]; + forAllSystems = nixpkgs.lib.genAttrs supportedSystems; + hermesModule = import ./nix/nixosModules.nix { inherit self; }; + perSystem = forAllSystems (system: let + pkgs = import nixpkgs { inherit system; }; + package = import ./nix/packages.nix { + inherit pkgs; + version = "0.51.0"; + }; + moduleChecks = if builtins.elem system linuxSystems then + let + moduleConfig = nixpkgs.lib.nixosSystem { + inherit system; + modules = [ + hermesModule + { + services.hermes-webui = { + enable = true; + package = package; + host = "0.0.0.0"; + port = 8787; + stateDir = "/var/lib/hermes-webui"; + agent.dir = "/var/lib/hermes-agent"; + }; + } + ]; + }; + moduleServiceEnvironment = nixpkgs.lib.concatStringsSep "\n" moduleConfig.config.systemd.services.hermes-webui.serviceConfig.Environment; + envProbe = pkgs.writeText "hermes-webui-nixos-env-${system}.txt" moduleServiceEnvironment; + in + { + module-env-mapping = pkgs.runCommand "hermes-webui-nixos-module-${system}" { + nativeBuildInputs = [ pkgs.coreutils ]; + } '' + grep -q 'HERMES_WEBUI_HOST=0.0.0.0' ${envProbe} + grep -q 'HERMES_WEBUI_PORT=8787' ${envProbe} + grep -q 'HERMES_WEBUI_STATE_DIR=/var/lib/hermes-webui' ${envProbe} + grep -q 'HERMES_WEBUI_AGENT_DIR=/var/lib/hermes-agent' ${envProbe} + touch "$out" + ''; + } + else + { }; + in + { + packages = { + hermes-webui = package; + default = package; + }; + + apps = { + default = { + type = "app"; + program = "${package}/bin/hermes-webui"; + }; + }; + + checks = moduleChecks // { + package = package; + }; + }); + in + { + packages = forAllSystems (system: perSystem.${system}.packages); + apps = forAllSystems (system: perSystem.${system}.apps); + checks = nixpkgs.lib.genAttrs linuxSystems (system: perSystem.${system}.checks); + + nixosModules = { + default = hermesModule; + hermes-webui = hermesModule; + }; + }; +} diff --git a/nix/nixosModules.nix b/nix/nixosModules.nix new file mode 100644 index 00000000..71adbb9c --- /dev/null +++ b/nix/nixosModules.nix @@ -0,0 +1,201 @@ +{ self }: +{ config, lib, pkgs, ... }: + +let + cfg = config.services.hermes-webui; + defaultPackage = self.packages.${pkgs.stdenv.hostPlatform.system}.default; + defaultStateDir = "/var/lib/hermes-webui"; + + protectedEnvironment = [ + "HERMES_WEBUI_HOST" + "HERMES_WEBUI_PORT" + "HERMES_WEBUI_STATE_DIR" + "HERMES_HOME" + "HERMES_WEBUI_AGENT_DIR" + "HERMES_WEBUI_PYTHON" + ]; + + protectedEnvironmentFileCheck = pkgs.writeShellScript "hermes-webui-protected-envfile-check" '' + set -eu + for env_file in "$@"; do + [ -f "$env_file" ] || continue + while IFS= read -r raw_line || [ -n "$raw_line" ]; do + line=$(printf '%s' "$raw_line" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//') + case "$line" in + ""|\#*) continue ;; + export\ *) line=''${line#export } ;; + esac + key=''${line%%=*} + case "$key" in + HERMES_WEBUI_HOST|HERMES_WEBUI_PORT|HERMES_WEBUI_STATE_DIR|HERMES_HOME|HERMES_WEBUI_AGENT_DIR|HERMES_WEBUI_PYTHON) + echo "environmentFiles must not set protected WebUI runtime key $key; use module options or extraEnvironment for supported keys." >&2 + exit 1 + ;; + esac + done < "$env_file" + done + ''; + + inferredAgentPython = + if cfg.agent.package == null then + null + else if (cfg.agent.package ? passthru) && (cfg.agent.package.passthru ? hermesVenv) then + "${cfg.agent.package.passthru.hermesVenv}/bin/python3" + else + null; + + inferredAgentDir = + if cfg.agent.package == null then + null + else + "${cfg.agent.package}/share/hermes-agent"; + + mappedEnvironment = (lib.mapAttrsToList + (name: value: "${name}=${value}") + ({ + HERMES_WEBUI_HOST = cfg.host; + HERMES_WEBUI_PORT = toString cfg.port; + HERMES_WEBUI_STATE_DIR = cfg.stateDir; + } + // lib.optionalAttrs (cfg.hermesHome != null) { + HERMES_HOME = cfg.hermesHome; + } + // lib.optionalAttrs (cfg.agent.dir != null) { + HERMES_WEBUI_AGENT_DIR = cfg.agent.dir; + } + // lib.optionalAttrs (cfg.agent.dir == null && inferredAgentDir != null) { + HERMES_WEBUI_AGENT_DIR = inferredAgentDir; + } + // lib.optionalAttrs (cfg.agent.dir == null && inferredAgentPython != null) { + HERMES_WEBUI_PYTHON = inferredAgentPython; + } + // lib.filterAttrs + (name: _: !(lib.elem name protectedEnvironment)) + cfg.extraEnvironment)); + + needsWritableStateDir = cfg.stateDir != defaultStateDir; + writableServiceDirs = + lib.optionals needsWritableStateDir [ cfg.stateDir ] + ++ lib.optionals (cfg.hermesHome != null) [ cfg.hermesHome ]; + + userspaceDirs = lib.optionals (cfg.hermesHome != null) [ + "d ${cfg.hermesHome} 2770 ${cfg.user} ${cfg.group} - -" + ]; + + tmpfilesRules = + (lib.optionals needsWritableStateDir [ + "d ${cfg.stateDir} 2770 ${cfg.user} ${cfg.group} - -" + ]) + ++ userspaceDirs; +in +{ + options.services.hermes-webui = { + enable = lib.mkEnableOption "Hermes WebUI service"; + + package = lib.mkOption { + type = lib.types.package; + default = defaultPackage; + defaultText = lib.literalExpression "self.packages.${pkgs.stdenv.hostPlatform.system}.default"; + description = "Package that provides the `bin/hermes-webui` executable."; + }; + + user = lib.mkOption { + type = lib.types.str; + default = "hermes-webui"; + description = "User that runs the Hermes WebUI service."; + }; + + group = lib.mkOption { + type = lib.types.str; + default = "hermes-webui"; + description = "Group that runs the Hermes WebUI service."; + }; + + host = lib.mkOption { + type = lib.types.str; + default = "127.0.0.1"; + description = "Value for HERMES_WEBUI_HOST."; + }; + + port = lib.mkOption { + type = lib.types.port; + default = 8787; + description = "Value for HERMES_WEBUI_PORT."; + }; + + stateDir = lib.mkOption { + type = lib.types.strMatching "^/.+"; + default = defaultStateDir; + defaultText = lib.literalExpression ''"/var/lib/hermes-webui"''; + description = "Value for HERMES_WEBUI_STATE_DIR."; + }; + + hermesHome = lib.mkOption { + type = lib.types.nullOr (lib.types.strMatching "^/.+"); + default = null; + description = "Optional value for HERMES_HOME."; + }; + + agent = { + package = lib.mkOption { + type = lib.types.nullOr lib.types.package; + default = null; + description = "Package to derive HERMES_WEBUI_AGENT_DIR from when `agent.dir` is unset."; + }; + + dir = lib.mkOption { + type = lib.types.nullOr (lib.types.strMatching "^/.+"); + default = null; + description = "Explicit path for HERMES_WEBUI_AGENT_DIR."; + }; + }; + + environmentFiles = lib.mkOption { + type = lib.types.listOf (lib.types.strMatching "^/.+"); + default = [ ]; + description = "Paths with extra environment variables for the service, including API keys. Protected WebUI runtime keys from module options are rejected here."; + }; + + extraEnvironment = lib.mkOption { + type = lib.types.attrsOf lib.types.str; + default = { }; + description = "Additional environment entries for the service. Required WebUI variables remain enforced."; + }; + }; + + config = lib.mkIf cfg.enable { + systemd.services.hermes-webui = { + description = "Hermes Web UI service"; + after = [ "network-online.target" ]; + wants = [ "network-online.target" ]; + wantedBy = [ "multi-user.target" ]; + + serviceConfig = + { + Type = "simple"; + User = cfg.user; + Group = cfg.group; + ExecStartPre = lib.optional (cfg.environmentFiles != [ ]) "${protectedEnvironmentFileCheck} ${lib.escapeShellArgs (map builtins.toString cfg.environmentFiles)}"; + ExecStart = "${cfg.package}/bin/hermes-webui"; + Restart = "on-failure"; + Environment = mappedEnvironment; + EnvironmentFile = map builtins.toString cfg.environmentFiles; + ReadWritePaths = map builtins.toString writableServiceDirs; + } + // lib.optionalAttrs (cfg.stateDir == defaultStateDir) { + StateDirectory = "hermes-webui"; + }; + }; + + users.groups.${cfg.group} = { }; + + users.users.${cfg.user} = { + isSystemUser = true; + group = cfg.group; + createHome = true; + home = cfg.stateDir; + }; + + systemd.tmpfiles.rules = tmpfilesRules; + }; +} diff --git a/nix/packages.nix b/nix/packages.nix new file mode 100644 index 00000000..a84cf793 --- /dev/null +++ b/nix/packages.nix @@ -0,0 +1,43 @@ +{ pkgs, version ? "0.51.0" }: + +let + pythonEnv = pkgs.python3.withPackages ( + ps: with ps; [ + pyyaml + cryptography + ] + ); + + runtimeDir = "hermes-webui"; +in +pkgs.stdenv.mkDerivation { + pname = "hermes-webui"; + inherit version; + + dontUnpack = true; + dontBuild = true; + nativeBuildInputs = [ pkgs.makeWrapper ]; + + installPhase = '' + runHook preInstall + + mkdir -p "$out/${runtimeDir}" "$out/bin" + + cp "${./../bootstrap.py}" "$out/${runtimeDir}/bootstrap.py" + cp "${./../server.py}" "$out/${runtimeDir}/server.py" + cp "${./../mcp_server.py}" "$out/${runtimeDir}/mcp_server.py" + cp "${./../requirements.txt}" "$out/${runtimeDir}/requirements.txt" + cp -r "${./../api}" "$out/${runtimeDir}/" + cp -r "${./../static}" "$out/${runtimeDir}/" + + makeWrapper ${pythonEnv}/bin/python3 "$out/bin/hermes-webui" \ + --add-flags "$out/${runtimeDir}/bootstrap.py --foreground --no-browser" + + runHook postInstall + ''; + + meta = { + description = "Hermes WebUI package"; + mainProgram = "hermes-webui"; + }; +}