feat: add nix packaging (#480)

* feat: add Nix flake and module for hermes-workspace deployment

* chore: modernize Nix build by migrating to generic nodejs/pnpm packages and adding direnv integration
This commit is contained in:
Mihir Rabade
2026-05-24 10:15:19 +10:00
committed by GitHub
parent b69aa347ad
commit 8e97068934
6 changed files with 535 additions and 0 deletions

3
.envrc Normal file
View File

@@ -0,0 +1,3 @@
#!/usr/bin/env bash
use flake

5
.gitignore vendored
View File

@@ -1,3 +1,8 @@
# Nix build outputs
result
result-*
.direnv/
# Dependencies # Dependencies
node_modules node_modules
.pnp .pnp

61
flake.lock generated Normal file
View File

@@ -0,0 +1,61 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1778869304,
"narHash": "sha256-30sZNZoA1cqF5JNO9fVX+wgiQYjB7HJqqJ4ztCDeBZE=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "d233902339c02a9c334e7e593de68855ad26c4cb",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

119
flake.nix Normal file
View File

@@ -0,0 +1,119 @@
{
description = "Hermes Workspace desktop workspace for Hermes Agent";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
};
outputs =
{
self,
nixpkgs,
flake-utils,
}:
let
# -----------------------------------------------------------------------
# NixOS module — available on all systems
# -----------------------------------------------------------------------
nixosModules.default = import ./nix/module.nix;
nixosModules.hermes-workspace = nixosModules.default;
# Overlay that adds hermes-workspace into any nixpkgs instance
overlays.default = final: _prev: {
hermes-workspace = final.callPackage ./nix/package.nix { };
};
overlays.hermes-workspace = overlays.default;
in
# -----------------------------------------------------------------------
# Per-system outputs
# -----------------------------------------------------------------------
flake-utils.lib.eachDefaultSystem (
system:
let
pkgs = import nixpkgs {
inherit system;
overlays = [ overlays.default ];
};
in
{
# -----------------------------------------------------------------
# Packages
# -----------------------------------------------------------------
packages = {
default = pkgs.hermes-workspace;
hermes-workspace = pkgs.hermes-workspace;
};
# -----------------------------------------------------------------
# Apps (nix run . or nix run .#hermes-workspace)
# -----------------------------------------------------------------
apps =
let
app = {
type = "app";
program = "${pkgs.hermes-workspace}/bin/hermes-workspace";
};
in
{
default = app;
hermes-workspace = app;
};
# -----------------------------------------------------------------
# Dev shell (nix develop)
# -----------------------------------------------------------------
devShells.default = pkgs.mkShell {
name = "hermes-workspace-dev";
packages = with pkgs; [
# Node / JS toolchain
nodejs
pnpm
typescript
# Python for pty-helper and build scripts
python3
# Nix tooling
nil # Nix LSP
nixfmt-rfc-style
];
shellHook = ''
echo ""
echo " 🚀 hermes-workspace dev shell"
echo " node $(node --version)"
echo " pnpm $(pnpm --version)"
echo " python $(python3 --version)"
echo ""
echo " Quick start:"
echo " pnpm install"
echo " pnpm dev # Vite dev server on :3000"
echo " pnpm build # Production build dist/"
echo " node server-entry.js # Serve production build"
echo ""
'';
};
# -----------------------------------------------------------------
# Formatter (nix fmt)
# -----------------------------------------------------------------
formatter = pkgs.nixfmt-rfc-style;
# -----------------------------------------------------------------
# Checks (nix flake check)
# -----------------------------------------------------------------
checks = {
# Verify the package evaluates without building it
package-eval = pkgs.runCommand "hermes-workspace-pkg-eval" { } ''
echo "Package evaluated: ${pkgs.hermes-workspace.name}" > $out
'';
};
}
)
// {
# Expose module + overlay at the top level (system-agnostic)
inherit nixosModules overlays;
};
}

241
nix/module.nix Normal file
View File

@@ -0,0 +1,241 @@
# NixOS module: services.hermes-workspace
#
# Runs the hermes-workspace web server as a systemd service.
# The companion hermes-agent gateway must be running separately
# (see https://github.com/NousResearch/hermes-agent).
#
# Minimal NixOS configuration example:
#
# services.hermes-workspace = {
# enable = true;
# hermesApiUrl = "http://127.0.0.1:8642";
# # For remote access, set a password and open the port:
# host = "0.0.0.0";
# passwordFile = config.sops.secrets."hermes-workspace-password".path;
# };
# networking.firewall.allowedTCPPorts = [ 3000 ];
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.hermes-workspace;
inherit (lib)
mkEnableOption
mkIf
mkOption
mkPackageOption
types
;
in
{
options.services.hermes-workspace = {
enable = mkEnableOption "Hermes Workspace web UI for Hermes Agent";
package = mkPackageOption pkgs "hermes-workspace" { };
port = mkOption {
type = types.port;
default = 3000;
description = "TCP port the workspace server listens on.";
};
host = mkOption {
type = types.str;
default = "127.0.0.1";
description = ''
Address to bind the HTTP server to.
Set to "0.0.0.0" to expose on all interfaces (requires passwordFile
or allowInsecureRemote = true).
'';
};
hermesApiUrl = mkOption {
type = types.str;
default = "http://127.0.0.1:8642";
description = ''
URL of the Hermes Agent gateway HTTP API.
Requires API_SERVER_ENABLED=true in the gateway's environment.
'';
};
hermesDashboardUrl = mkOption {
type = types.str;
default = "http://127.0.0.1:9119";
description = "URL of the Hermes Agent dashboard.";
};
passwordFile = mkOption {
type = types.nullOr types.path;
default = null;
description = ''
Path to a file whose first line is the workspace session password.
Required when host is not a loopback address.
Use a secrets manager (sops-nix, agenix, etc.) to manage this file.
'';
};
cookieSecure = mkOption {
type = types.nullOr types.bool;
default = null;
description = ''
Override the Secure flag on session cookies.
null means "auto" (enabled in production mode).
Set to false for plain-HTTP LAN deployments behind a proxy.
'';
};
trustProxy = mkOption {
type = types.bool;
default = false;
description = ''
Trust X-Forwarded-For / X-Real-IP headers from a reverse proxy.
Only enable when the server is behind a trusted proxy (Nginx, Traefik, etc.).
'';
};
allowInsecureRemote = mkOption {
type = types.bool;
default = false;
description = ''
Allow binding to non-loopback addresses without a password.
NOT recommended only use behind a custom auth layer.
'';
};
hermesWorldEnabled = mkOption {
type = types.bool;
default = true;
description = "Show the HermesWorld multiplayer link in the sidebar.";
};
extraEnvironment = mkOption {
type = types.attrsOf types.str;
default = { };
example = {
STREAM_ACCEPTED_TIMEOUT_MS = "120000";
VITE_PLAYGROUND_WS_URL = "wss://my-hub.example.com/playground";
};
description = "Extra environment variables passed to the service.";
};
environmentFile = mkOption {
type = types.nullOr types.path;
default = null;
description = ''
Path to a file containing additional environment variables
(KEY=value, one per line). Useful for secrets not covered by
the structured options above.
'';
};
user = mkOption {
type = types.str;
default = "hermes-workspace";
description = "System user to run the service as.";
};
group = mkOption {
type = types.str;
default = "hermes-workspace";
description = "System group to run the service as.";
};
dataDir = mkOption {
type = types.path;
default = "/var/lib/hermes-workspace";
description = ''
State directory for the workspace (sessions, runtime data).
The service user must have write access.
'';
};
};
config = mkIf cfg.enable {
users.users.${cfg.user} = lib.mkDefault {
isSystemUser = true;
group = cfg.group;
home = cfg.dataDir;
createHome = true;
description = "Hermes Workspace service user";
};
users.groups.${cfg.group} = lib.mkDefault { };
systemd.services.hermes-workspace = {
description = "Hermes Workspace Web Server";
documentation = [ "https://github.com/outsourc-e/hermes-workspace" ];
wantedBy = [ "multi-user.target" ];
after = [ "network.target" ];
environment =
{
NODE_ENV = "production";
PORT = toString cfg.port;
HOST = cfg.host;
HERMES_API_URL = cfg.hermesApiUrl;
HERMES_DASHBOARD_URL = cfg.hermesDashboardUrl;
VITE_HERMESWORLD_ENABLED = if cfg.hermesWorldEnabled then "1" else "0";
TRUST_PROXY = if cfg.trustProxy then "1" else "0";
HERMES_ALLOW_INSECURE_REMOTE = if cfg.allowInsecureRemote then "1" else "0";
# Point HOME to the data dir so session files land there
HOME = cfg.dataDir;
}
// lib.optionalAttrs (cfg.cookieSecure != null) {
COOKIE_SECURE = if cfg.cookieSecure then "1" else "0";
}
// cfg.extraEnvironment;
serviceConfig = {
Type = "simple";
User = cfg.user;
Group = cfg.group;
WorkingDirectory = cfg.dataDir;
ExecStart = "${lib.getExe cfg.package}";
# Load the password file as an env file when specified.
# The file must contain: HERMES_PASSWORD=<value>
EnvironmentFile = lib.optional (cfg.passwordFile != null) cfg.passwordFile
++ lib.optional (cfg.environmentFile != null) cfg.environmentFile;
# Restart on failure with backoff
Restart = "on-failure";
RestartSec = "5s";
StartLimitIntervalSec = "120";
StartLimitBurst = "5";
# Runtime directories
RuntimeDirectory = "hermes-workspace";
StateDirectory = lib.removePrefix "/var/lib/" cfg.dataDir;
LogsDirectory = "hermes-workspace";
# Security hardening (balanced against PTY + terminal needs)
NoNewPrivileges = true;
PrivateTmp = true;
ProtectSystem = "strict";
ProtectHome = true;
ReadWritePaths = [ cfg.dataDir ];
# PTY helper needs /dev/ptmx and /dev/pts
PrivateDevices = false;
DeviceAllow = [
"/dev/ptmx rw"
"char-pts rw"
];
ProtectKernelTunables = true;
ProtectControlGroups = true;
ProtectKernelModules = true;
RestrictNamespaces = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
LockPersonality = true;
MemoryDenyWriteExecute = false; # Node.js JIT requires this off
SystemCallFilter = "@system-service";
SystemCallErrorNumber = "EPERM";
};
};
};
}

106
nix/package.nix Normal file
View File

@@ -0,0 +1,106 @@
{
lib,
stdenv,
nodejs,
pnpm,
fetchPnpmDeps,
pnpmConfigHook,
python3,
makeWrapper,
}:
stdenv.mkDerivation (finalAttrs: {
pname = "hermes-workspace";
version = "2.3.0";
src = lib.cleanSourceWith {
src = ../.;
filter = name: type:
let
baseName = builtins.baseNameOf name;
relPath = lib.removePrefix (toString ../.) name;
in
# Exclude dirs that don't affect the build
!(lib.hasPrefix "/.git" relPath)
&& !(lib.hasPrefix "/node_modules" relPath)
&& !(lib.hasPrefix "/dist" relPath)
&& !(lib.hasPrefix "/.output" relPath)
&& !(lib.hasPrefix "/.tanstack" relPath)
&& !(lib.hasPrefix "/.vinxi" relPath)
&& !(lib.hasPrefix "/release" relPath)
&& !(lib.hasPrefix "/electron/server-bundle.cjs" relPath)
&& !(lib.hasPrefix "/memory" relPath)
&& !(lib.hasPrefix "/screenshots" relPath)
&& baseName != ".env"
&& baseName != ".env.local";
};
pnpmDeps = fetchPnpmDeps {
inherit (finalAttrs) pname version src;
pnpm = pnpm; # Ensure fetcher uses the same pnpm binary as the build
fetcherVersion = 3;
hash = "sha256-cgK1/KQkA9zOb1Zn5/OjV9qTXQEIVBaTWldbCbdRULs=";
};
nativeBuildInputs = [
nodejs
pnpm # provides the pnpm binary used by pnpmConfigHook
pnpmConfigHook
makeWrapper
];
buildInputs = [ python3 ];
# Give the build plenty of memory — same as the package.json script
NODE_OPTIONS = "--max-old-space-size=2048";
# Vite / TanStack Start require NODE_ENV=production for the SSR build so
# runtime env vars aren't inlined into client bundles.
NODE_ENV = "production";
buildPhase = ''
runHook preBuild
pnpm run build
runHook postBuild
'';
installPhase = ''
runHook preInstall
local appDir="$out/lib/hermes-workspace"
mkdir -p "$appDir"
# Copy build artefacts and runtime sources
cp -r dist "$appDir/"
cp -r node_modules "$appDir/"
cp -r skills "$appDir/"
cp package.json server-entry.js "$appDir/"
# pty-helper.py: Vite's copy-pty-helper plugin writes it during build
# but we also ensure it's present here as a belt-and-suspenders measure.
local ptyHelper="$appDir/dist/server/assets/pty-helper.py"
if [ ! -f "$ptyHelper" ]; then
mkdir -p "$(dirname "$ptyHelper")"
cp src/server/pty-helper.py "$ptyHelper"
fi
# Create a wrapper script so the binary lands in $out/bin
mkdir -p "$out/bin"
makeWrapper "${nodejs}/bin/node" "$out/bin/hermes-workspace" \
--add-flags "--max-old-space-size=2048" \
--add-flags "$appDir/server-entry.js" \
--set NODE_ENV "production" \
--prefix PATH : "${python3}/bin"
runHook postInstall
'';
meta = {
description = "Desktop workspace for Hermes Agent chat, orchestration, and multi-agent coding pipelines";
homepage = "https://github.com/outsourc-e/hermes-workspace";
license = lib.licenses.mit;
maintainers = [ ];
platforms = lib.platforms.linux ++ lib.platforms.darwin;
mainProgram = "hermes-workspace";
};
})