Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license

This commit is contained in:
2026-09-05 13:26:46 +03:00
commit 03634b1ca3
11340 changed files with 3442369 additions and 0 deletions
+1454
View File
File diff suppressed because it is too large Load Diff
+33
View File
@@ -0,0 +1,33 @@
# nix/configMergeScript.nix — Deep-merge Nix settings into existing config.yaml
#
# Used by the NixOS module activation script and by checks.nix tests.
# Nix keys override; user-added keys (skills, streaming, etc.) are preserved.
{ pkgs }:
pkgs.writeScript "hermes-config-merge" ''
#!${pkgs.python3.withPackages (ps: [ ps.pyyaml ])}/bin/python3
import json, yaml, sys
from pathlib import Path
nix_json, config_path = sys.argv[1], Path(sys.argv[2])
with open(nix_json) as f:
nix = json.load(f)
existing = {}
if config_path.exists():
with open(config_path) as f:
existing = yaml.safe_load(f) or {}
def deep_merge(base, override):
result = dict(base)
for k, v in override.items():
if k in result and isinstance(result[k], dict) and isinstance(v, dict):
result[k] = deep_merge(result[k], v)
else:
result[k] = v
return result
merged = deep_merge(existing, nix)
with open(config_path, "w") as f:
yaml.dump(merged, f, default_flow_style=False, sort_keys=False)
''
+217
View File
@@ -0,0 +1,217 @@
# nix/desktop.nix — Hermes Desktop (Electron) app build + wrapper
#
# `hermesAgent` is the fully-built `.#default` package — it ships the
# `hermes` binary with the venv, runtime PATH, bundled skills/plugins, etc.
# already wired up. We point the desktop at it via the existing
# `HERMES_DESKTOP_HERMES` override env var, so the desktop's resolver
# uses our fully wrapped binary at step 4 ("existing Hermes CLI").
# No reimplementation of the agent resolution in this wrapper.
{
pkgs,
lib,
stdenv,
makeWrapper,
hermesNpmLib,
electron,
hermesAgent,
python3,
# Environment to bake into the launcher. A GUI launcher reads none of the
# shell profile, so a variable that an interactive shell exports does not
# reach an app that the desktop menu starts. The Home Manager module passes
# HERMES_HOME and HERMES_MANAGED here, which gives the app the same state
# directory as the services.
extraEnv ? { },
# Shell lines to run before the app starts. A secret belongs here and never
# in extraEnv: makeWrapper writes a --set value into the Nix store, which
# all users can read. A --run line reads the value from a runtime path at
# each start instead.
extraRun ? [ ],
...
}:
let
# Each flag goes on its own continued line, and the leading backslash is
# inside the generated string. An empty attribute set then adds no text at
# all, and cannot leave a backslash above a blank line. That fault ends the
# makeWrapper command early, and the next flag runs as a shell command.
extraEnvFlags = lib.concatMapStrings (
name: " \\\n --set ${name} ${lib.escapeShellArg (toString extraEnv.${name})}"
) (lib.attrNames extraEnv);
extraRunFlags = lib.concatMapStrings (line: " \\\n --run ${lib.escapeShellArg line}") extraRun;
electronHeaders = pkgs.fetchurl {
url = "https://artifacts.electronjs.org/headers/dist/v${electron.version}/node-v${electron.version}-headers.tar.gz";
sha256 = "sha256-f8bSbLRmtbP93CJAvEBs+sHWDZ1xP2bcpLhC1EnOmZU=";
};
# node-pty ships no Electron-tagged prebuild we can trust to match this
# exact nixpkgs electron version, so it's always compiled from source
# against Electron's own headers (not whatever Node ran `npm`).
targetPlatform =
if stdenv.hostPlatform.isDarwin then
"darwin"
else if stdenv.hostPlatform.isLinux then
"linux"
else
throw "hermes-desktop: unsupported host platform for node-pty staging";
targetArch =
if stdenv.hostPlatform.isAarch64 then
"arm64"
else if stdenv.hostPlatform.isx86_64 then
"x64"
else
throw "hermes-desktop: unsupported host arch for node-pty staging";
# Build the renderer (dist/ + electron/ + package.json).
renderer = hermesNpmLib.buildNpmPackage {
dirs = [
"apps/desktop"
"apps/shared"
];
pname = "hermes-desktop-renderer";
doCheck = true;
buildPhase = ''
runHook preBuild
mkdir -p apps/desktop/build
patchShebangs .
pushd apps/desktop
# typecheck :3
npm exec -- tsc -b
# build the renderer bundle
# vite's emptyOutDir wipes dist/ on every run
# so it has to be first
npm exec -- vite build
# build the electron bundle
node scripts/bundle-electron-main.mjs
# Compile node-pty against Electron's actual ABI (the nixpkgs
# `electron` we ship). Headers come from a pinned fetchurl input
# since the sandbox has no network here, so node-gyp's
# normal --disturl download path can't run.
mkdir -p "$TMPDIR/electron-headers"
tar -xzf ${electronHeaders} -C "$TMPDIR/electron-headers" --strip-components=1
${lib.getExe hermesNpmLib.node-gyp} rebuild \
--directory=../../node_modules/node-pty \
--build-from-source \
--runtime=electron \
--target=${electron.version} \
--nodedir="$TMPDIR/electron-headers" \
--disturl="" \
--offline
# Target platform/arch come from stdenv.hostPlatform, not the
# build host's own process.platform/arch.
node scripts/stage-native-deps.mjs ${targetPlatform} ${targetArch}
popd
runHook postBuild
'';
checkPhase = ''
runHook preCheck
pushd apps/desktop
npm run postbuild
# validate staged node-pty native binary is present.
STAGED_PTY_NODE="./dist/node_modules/node-pty/build/Release/pty.node"
if [ ! -f "$STAGED_PTY_NODE" ]; then
echo "FATAL: Missing staged node-pty native binary at $STAGED_PTY_NODE"
echo "node-pty must be compiled natively"
exit 1
fi
popd
runHook postCheck
'';
installPhase = ''
runHook preInstall
mkdir -p $out
# vite writes to apps/desktop/dist/ (we cd'd there in buildPhase).
# stage-native-deps.mjs stages node-pty into dist/node_modules/node-pty,
# so copying dist/ wholesale carries the native dep along with the
# esbuild bundle that require()s it. apps/desktop/build was created
# before the cd.
cp -rn apps/desktop/dist $out/
echo '{"schemaVersion":1,"commit":"nix-dummy-commit","branch":"nix","dirty":false,"source":"nix"}' > $out/install-stamp.json
cp -n apps/desktop/package.json $out/
runHook postInstall
'';
};
in
# Electron wrapper: nixpkgs' electron binary pointed at the renderer dir.
stdenv.mkDerivation {
pname = "hermes-desktop";
inherit (renderer) version;
dontUnpack = true;
dontBuild = true;
nativeBuildInputs = [
makeWrapper
python3
];
installPhase = ''
runHook preInstall
mkdir -p $out/share/hermes-desktop $out/bin
cp -r ${renderer}/* $out/share/hermes-desktop/
# Standard nixpkgs pattern for electron-builder apps: patch process.resourcesPath
# to point to the app's directory. In Nix, unpackaged electron defaults this
# to the electron distribution's resources path, breaking extraResources lookups.
substituteInPlace $out/share/hermes-desktop/dist/electron-main.mjs \
--replace-fail "process.resourcesPath" "'$out/share/hermes-desktop'"
# Wrap the nixpkgs electron binary to launch our app. Set
# HERMES_DESKTOP_HERMES to the absolute path of the nix-built `hermes`
# binary so the desktop's resolver step 4 ("existing Hermes CLI on
# PATH") uses our fully wrapped binary venv with all deps,
# bundled skills/plugins, runtime PATH (ripgrep/git/ffmpeg/etc).
# No reimplementation of the agent resolver in the wrapper.
makeWrapper ${lib.getExe electron} $out/bin/hermes-desktop \
--add-flags "$out/share/hermes-desktop" \
--set HERMES_DESKTOP_HERMES "${lib.getExe hermesAgent}" \
--set ELECTRON_IS_DEV 0${extraEnvFlags}${extraRunFlags}
# XDG launcher entry
mkdir -p $out/share/applications $out/share/icons/hicolor/1024x1024/apps
install -m 0644 ${../apps/desktop/assets/icon.png} \
$out/share/icons/hicolor/1024x1024/apps/hermes.png
export PYTHONPATH=$(mktemp -d)
cp ${../hermes_cli/linux_desktop_entry.py} "$PYTHONPATH/linux_desktop_entry.py"
export DESKTOP_EXEC="$out/bin/hermes-desktop"
export DESKTOP_ICON="$out/share/icons/hicolor/1024x1024/apps/hermes.png"
python3 -c 'import os; from linux_desktop_entry import render_desktop_entry; print(render_desktop_entry(os.environ["DESKTOP_EXEC"], os.environ["DESKTOP_ICON"]))' > $out/share/applications/hermes.desktop
runHook postInstall
'';
passthru = {
inherit (renderer.passthru) packageJsonPath;
};
meta = with lib; {
description = "Native Electron desktop shell for Hermes Agent";
homepage = "https://github.com/NousResearch/hermes-agent";
license = licenses.mit;
platforms = platforms.unix;
mainProgram = "hermes-desktop";
};
}
+64
View File
@@ -0,0 +1,64 @@
# nix/devShell.nix — Dev shell that delegates setup to each package
#
# Each npm workspace package exposes passthru.packageJsonPath (e.g.
# "ui-tui/package.json"). This file collects them all and passes the
# list to mkNpmDevShellHook, which stamps all package.jsons at once,
# then runs a single `npm i --package-lock-only` if any changed and
# `npm ci` if the lockfile changed.
{ ... }:
{
perSystem =
{ pkgs, self', ... }:
let
packages = builtins.attrValues self'.packages;
hermesNpmLib = self'.packages.default.passthru.hermesNpmLib;
# Collect all packageJsonPath values from npm workspace packages.
npmPackageJsonPaths = builtins.filter (p: p != null) (
map (p: p.passthru.packageJsonPath or null) packages
);
hermesAgentDevShellHook = self'.packages.default.passthru.devShellHook;
in
{
devShells.default = pkgs.mkShell {
packages = with pkgs; [
(pkgs.runCommand "hermes" { } ''
mkdir -p $out/bin
install -Dm755 ${../hermes} $out/bin/hermes
'')
self'.packages.sandbox
uv
# Headless Wayland compositor for E2E tests (test:e2e:visual).
# cage renders a single client with no window management, so
# the Electron window opens at a fixed size without tiling.
# libglvnd provides libEGL.so.1 that cage needs on NixOS.
cage
libglvnd
# Graphical terminal + Wayland screenshot client for CLI/TUI UI
# evidence. `cage -- ghostty ...` keeps captures off the user's
# live compositor; grim runs inside that isolated client session.
ghostty
grim
]
++ self'.packages.default.passthru.devDeps;
shellHook = ''
${hermesAgentDevShellHook}
${hermesNpmLib.mkNpmDevShellHook npmPackageJsonPaths}
# Force Node to use Nix's playwright-test binary instead of node_modules/.bin
export PATH="${pkgs.playwright-test}/bin:$PATH"
# for the devshell to pick up the src
export HERMES_PYTHON_SRC_ROOT=$(git rev-parse --show-toplevel)
# Let `uv run --active --no-sync` reuse Nix's provisioned Python
# environment instead of creating an empty project .venv.
export VIRTUAL_ENV="$(dirname "$(dirname "$(readlink -f "$(command -v python)")")")"
echo "Hermes Agent dev shell in $HERMES_PYTHON_SRC_ROOT"
echo "Ready. Run 'hermes' or 'sandbox hermes' to start."
'';
};
};
}
+271
View File
@@ -0,0 +1,271 @@
# nix/hermes-agent.nix — Overridable Hermes Agent package
#
# callPackage auto-wires nixpkgs args; flake inputs are passed explicitly.
# Users override via:
# pkgs.hermes-agent.override { extraPythonPackages = [...]; }
# pkgs.hermes-agent.override { extraDependencyGroups = [ "hindsight" ]; }
{
lib,
stdenv,
makeWrapper,
callPackage,
python312,
electron,
ripgrep,
git,
openssh,
ffmpeg,
tirith,
# linux-only deps
wl-clipboard,
xclip,
# linux-only dev deps
cage,
# Flake inputs — passed explicitly by packages.nix and overlays.nix
uv2nix,
pyproject-nix,
pyproject-build-systems,
npm-lockfile-fix,
# Locked git revision of the flake source — embedded so banner.py can
# check for updates without needing a local .git directory. Null for
# impure / dirty builds where flakes can't determine a rev.
rev ? null,
# Overridable parameters
extraPythonPackages ? [ ],
extraDependencyGroups ? [ ],
}:
let
mkHermesVenv =
extraDependencyGroups:
callPackage ./python.nix {
inherit uv2nix pyproject-nix pyproject-build-systems;
pythonSrc = hermesNpmLib.pythonSrc;
dependency-groups = [ "all" ] ++ extraDependencyGroups;
};
hermesVenv = (mkHermesVenv extraDependencyGroups).venv;
hermesNpmLib = callPackage ./lib.nix {
inherit npm-lockfile-fix;
};
hermesTui = callPackage ./tui.nix {
inherit hermesNpmLib;
};
hermesWeb = callPackage ./web.nix {
inherit hermesNpmLib;
};
bundledSkills = lib.cleanSourceWith {
src = ../skills;
filter = path: _type: !(lib.hasInfix "/index-cache/" path) && !(lib.hasInfix "/__pycache__/" path);
};
# Optional skills are NOT in the wheel (pythonSrc excludes them, see
# lib.nix) — the wrapper exposes them via HERMES_OPTIONAL_SKILLS, the
# same mechanism Homebrew packaging uses.
bundledOptionalSkills = lib.cleanSourceWith {
src = ../optional-skills;
filter = path: _type: !(lib.hasInfix "/index-cache/" path) && !(lib.hasInfix "/__pycache__/" path);
};
# Import bundled plugins (memory, context_engine, platforms/*). Keeping
# them out of the Python site-packages keeps import semantics identical
# to a dev checkout — the loader reads them from HERMES_BUNDLED_PLUGINS.
bundledPlugins = lib.cleanSourceWith {
src = ../plugins;
filter = path: _type: !(lib.hasInfix "/__pycache__/" path);
};
# i18n locale catalogs (locales/*.yaml). Shipped into the store and pointed
# at by HERMES_BUNDLED_LOCALES so the wrapped binary always resolves human
# strings instead of raw i18n keys (#23943 / #27632 / #35374).
bundledLocales = lib.cleanSource ../locales;
# Shipped MCP catalog (optional-mcps/<name>/manifest.yaml). Same bare-data-dir
# case as locales: not a Python package, so it's symlinked into the store and
# exposed via HERMES_OPTIONAL_MCPS.
bundledOptionalMcps = lib.cleanSourceWith {
src = ../optional-mcps;
filter = path: _type: !(lib.hasInfix "/__pycache__/" path);
};
runtimeDeps = [
hermesNpmLib.nodejs
ripgrep
git
openssh
ffmpeg
tirith
]
++ lib.optionals stdenv.isLinux [
wl-clipboard
xclip
];
runtimePath = lib.makeBinPath runtimeDeps;
sitePackagesPath = python312.sitePackages;
# Walk propagatedBuildInputs to include transitive Python deps in PYTHONPATH.
# Without this, a plugin listing e.g. requests as a dep would fail at runtime
# if requests isn't already in the sealed uv2nix venv.
allExtraPythonPackages = python312.pkgs.requiredPythonModules extraPythonPackages;
pythonPath = lib.makeSearchPath sitePackagesPath allExtraPythonPackages;
checkPackageCollisions = ''
import pathlib, sys, re
def canonical(name):
return re.sub(r'[-_.]+', '-', name).lower()
# Collect core venv package names
core = set()
venv_sp = pathlib.Path('${hermesVenv}/${sitePackagesPath}')
for di in venv_sp.glob('*.dist-info'):
meta = di / 'METADATA'
if meta.exists():
for line in meta.read_text().splitlines():
if line.startswith('Name:'):
core.add(canonical(line.split(':', 1)[1].strip()))
break
# Check each extra package for collisions
extras_dirs = [${lib.concatMapStringsSep ", " (p: "'${toString p}'") allExtraPythonPackages}]
for edir in extras_dirs:
sp = pathlib.Path(edir) / '${sitePackagesPath}'
if not sp.exists():
continue
for di in sp.glob('*.dist-info'):
meta = di / 'METADATA'
if not meta.exists():
continue
for line in meta.read_text().splitlines():
if line.startswith('Name:'):
pkg = canonical(line.split(':', 1)[1].strip())
if pkg in core:
print(f'ERROR: plugin package \"{pkg}\" collides with a package in hermes sealed venv', file=sys.stderr)
print(f' from: {di}', file=sys.stderr)
print(f' Remove this dependency from extraPythonPackages.', file=sys.stderr)
sys.exit(1)
break
print('No collisions found.')
'';
in
stdenv.mkDerivation (finalAttrs: {
pname = "hermes-agent";
version = (fromTOML (builtins.readFile ../pyproject.toml)).project.version;
dontUnpack = true;
dontBuild = true;
nativeBuildInputs = [ makeWrapper ];
installPhase = ''
runHook preInstall
# Symlinks, not copies: these are all store paths already, and the
# wrapper env vars just hold paths. Symlinking keeps this derivation
# near-instant when only the venv changed, with an identical closure.
mkdir -p $out/share/hermes-agent $out/bin
ln -s ${bundledSkills} $out/share/hermes-agent/skills
ln -s ${bundledOptionalSkills} $out/share/hermes-agent/optional-skills
ln -s ${bundledPlugins} $out/share/hermes-agent/plugins
ln -s ${bundledLocales} $out/share/hermes-agent/locales
ln -s ${bundledOptionalMcps} $out/share/hermes-agent/optional-mcps
ln -s ${hermesWeb} $out/share/hermes-agent/web_dist
ln -s ${hermesTui}/lib/hermes-tui $out/ui-tui
${lib.concatMapStringsSep "\n"
(name: ''
makeWrapper ${hermesVenv}/bin/${name} $out/bin/${name} \
--suffix PATH : "${runtimePath}" \
--set HERMES_BUNDLED_SKILLS $out/share/hermes-agent/skills \
--set HERMES_OPTIONAL_SKILLS $out/share/hermes-agent/optional-skills \
--set HERMES_BUNDLED_PLUGINS $out/share/hermes-agent/plugins \
--set HERMES_BUNDLED_LOCALES $out/share/hermes-agent/locales \
--set HERMES_OPTIONAL_MCPS $out/share/hermes-agent/optional-mcps \
--set HERMES_WEB_DIST $out/share/hermes-agent/web_dist \
--set HERMES_TUI_DIR $out/ui-tui \
--set-default HERMES_BIN $out/bin/hermes \
--set HERMES_PYTHON ${hermesVenv}/bin/python3 \
--set HERMES_NODE ${lib.getExe hermesNpmLib.nodejs}${
# Fold the line continuation INTO the optionalString: a bare
# `\` on the line above an empty expansion would dangle onto a
# blank line, ending the makeWrapper command early and running
# the next flag as its own shell command (`--suffix: command
# not found`). Only reproduces when rev == null (dirty trees).
lib.optionalString (rev != null) " \\\n --set HERMES_REVISION ${rev}"
}${
lib.optionalString (
extraPythonPackages != [ ]
) " \\\n --suffix PYTHONPATH : \"${pythonPath}\""
}
'')
[
"hermes"
"hermes-agent"
"hermes-acp"
]
}
${lib.optionalString (extraPythonPackages != [ ]) ''
echo "=== Checking for plugin/core package collisions ==="
${hermesVenv}/bin/python3 -c "${checkPackageCollisions}"
echo "=== No collisions ==="
''}
runHook postInstall
'';
passthru =
let
devPython = (mkHermesVenv (extraDependencyGroups ++ [ "dev" ])).editableVenv;
in
{
inherit
hermesTui
hermesWeb
hermesNpmLib
hermesVenv
;
# `hermesDesktop` references `finalAttrs.finalPackage` (this whole
# derivation, after all overrides are applied) so the desktop wrapper
# can prepend its `/bin` to PATH. The desktop's resolver step 4
# ("existing hermes on PATH") then picks up the fully wrapped
# `hermes` binary — venv with all deps, bundled skills/plugins,
# runtime PATH (ripgrep/git/ffmpeg/etc). No re-implementation
# of the agent resolution in the desktop wrapper.
hermesDesktop = callPackage ./desktop.nix {
inherit hermesNpmLib electron;
hermesAgent = finalAttrs.finalPackage;
};
devShellHook = ''
export HERMES_PYTHON=${devPython}/bin/python3
'';
devDeps =
runtimeDeps
++ [
devPython
]
++ lib.optionals stdenv.isLinux [
cage # for running e2e tests without popping windows
];
};
meta = with lib; {
description = "AI agent with advanced tool-calling capabilities";
homepage = "https://github.com/NousResearch/hermes-agent";
mainProgram = "hermes";
license = licenses.mit;
platforms = platforms.unix;
};
})
+428
View File
@@ -0,0 +1,428 @@
# nix/homeManagerModules.nix — the Home Manager module for hermes-agent
#
# This module is the user-level equivalent of nixosModules.default. Hermes is
# an agent for one person. The credentials, the memory, the sessions and the
# cron jobs all belong to that person. Thus a user-level module is correct on
# each distribution, and not only on NixOS.
#
# `services.hermes-agent` is the same option set on both modules. All of the
# options except the system-level ones come from nix/moduleCommon.nix, so an
# example from the NixOS documentation works here without a change. Only the
# necessary parts are different:
#
# removed user, group, createUser — Home Manager runs as the user
# removed container.* — it needs root and the Docker socket
# removed UMask 0007 — that mode shares state with a UNIX
# group, but this state has one user
# changed systemd.services -> systemd.user.services or
# launchd.agents
# changed system.activationScripts -> home.activation
# changed addToSystemPackages -> programs.hermes-agent.enable and
# home.sessionVariables
# added programs.hermes-agent the CLI and the desktop application,
# because Home Manager separates an
# installation from a daemon
# changed stateDir (+ "/.hermes") -> hermesHome, set directly
#
# To use the module:
# imports = [ hermes-agent.homeManagerModules.default ];
# programs.hermes-agent = {
# enable = true; # the hermes CLI on your PATH
# desktop.enable = true; # the Electron application and a launcher
# };
# services.hermes-agent = {
# enable = true;
# gateway.enable = true;
# settings.model.default = "anthropic/claude-sonnet-4";
# environmentFiles = [ config.sops.secrets."hermes/env".path ];
# };
#
# CAUTION: Enable linger for the account. Without linger, systemd stops the
# user manager at logout, and both units stop with it. Home Manager cannot
# run `loginctl enable-linger`. On NixOS, set
# users.users.<name>.linger = true;
# On other systems, run `loginctl enable-linger <name>` one time.
{ inputs, ... }:
{
flake.homeManagerModules.default =
{
config,
lib,
options,
pkgs,
...
}:
let
cfg = config.services.hermes-agent;
cfgPrograms = config.programs.hermes-agent;
common = import ./moduleCommon.nix { inherit lib; };
effectivePackage = common.effectivePackage cfg;
hermes-agent = inputs.self.packages.${pkgs.stdenv.hostPlatform.system}.default;
inherit (pkgs.stdenv.hostPlatform) isDarwin isLinux;
processEnvironment = common.processEnvironment {
inherit (cfg) hermesHome;
# The CLI reads this value and names it when it refuses a
# configuration change.
managedSystem = "home-manager";
};
unitPath = lib.makeBinPath (common.processPath { inherit pkgs cfg; });
# ── The desktop launcher ───────────────────────────────────────────
# A GUI launcher reads no shell profile, so home.sessionVariables does
# not reach it, and the application would open ~/.hermes while the
# services use hermesHome. Thus the launcher carries the value itself.
#
# HERMES_MANAGED rides along only when the services are enabled. That
# variable makes the CLI refuse a configuration change and name the
# rebuild command. A person who enables `programs.` alone has no
# activation and no managed configuration, so the application must not
# claim one and refuse an edit that nothing else owns.
desktopEnvironment = {
HERMES_HOME = cfg.hermesHome;
}
// lib.optionalAttrs cfg.enable {
inherit (processEnvironment) HERMES_MANAGED;
}
// lib.optionalAttrs desktopUsesService {
HERMES_DESKTOP_REMOTE_URL = "http://${cfg.backend.host}:${toString cfg.backend.port}";
};
# The application reaches the backend of the service only when there is
# a backend to reach AND a shared token to present with. Without the
# token the desktop resolver throws ("HERMES_DESKTOP_REMOTE_URL is set
# but HERMES_DESKTOP_REMOTE_TOKEN is not"), so the two variables travel
# together or not at all.
desktopUsesService = cfg.enable && cfg.backend.mode != "none" && cfg.backend.sessionTokenFile != null;
# The token is read at start time and never with `--set`. makeWrapper
# writes a --set value into the Nix store, which all users can read.
desktopRun = lib.optional desktopUsesService ''
if [ -r ${lib.escapeShellArg cfg.backend.sessionTokenFile} ]; then
HERMES_DESKTOP_REMOTE_TOKEN="$(tr -d '\r\n' < ${lib.escapeShellArg cfg.backend.sessionTokenFile})"
export HERMES_DESKTOP_REMOTE_TOKEN
else
echo "hermes-desktop: cannot read the session token at ${cfg.backend.sessionTokenFile}." >&2
echo "hermes-desktop: the application starts its own backend instead of the one of the service." >&2
fi
'';
# `override`, and not `overrideAttrs`: the values go into the wrapper
# that the installPhase writes, and not into a derivation attribute.
desktopPackage = cfgPrograms.desktop.package.override {
extraEnv = desktopEnvironment;
extraRun = desktopRun;
};
# The systemd unit that the gateway and the backend both start from.
mkUnit =
{
description,
argv,
}:
{
Unit = {
Description = description;
# Do not use network-online.target here. That is a system target.
# A user unit that orders against it has no effect, and systemd
# gives no message.
After = [ "default.target" ];
};
Install.WantedBy = [ "default.target" ];
Service = {
Type = "simple";
Environment = (lib.mapAttrsToList (k: v: "${k}=${v}") processEnvironment) ++ [
"PATH=${unitPath}"
];
ExecStart = lib.escapeShellArgs argv;
WorkingDirectory = cfg.workingDirectory;
Restart = cfg.restart;
RestartSec = cfg.restartSec;
# This state has one user. Keep it private. The NixOS module uses
# 0007 to share the state with a UNIX group.
UMask = "0077";
NoNewPrivileges = true;
PrivateTmp = true;
};
};
mkAgent =
{ argv, logName }:
{
enable = true;
config = {
Label = "org.nix-community.home.${logName}";
ProgramArguments = argv;
EnvironmentVariables = processEnvironment // {
PATH = "${unitPath}:/usr/bin:/bin:/usr/sbin:/sbin";
};
WorkingDirectory = cfg.workingDirectory;
RunAtLoad = true;
KeepAlive =
if cfg.restart == "always" then
true
else
{
SuccessfulExit = false;
Crashed = true;
};
ThrottleInterval = cfg.restartSec;
StandardOutPath = "${config.home.homeDirectory}/Library/Logs/${logName}.log";
StandardErrorPath = "${config.home.homeDirectory}/Library/Logs/${logName}.err.log";
ProcessType = "Background";
};
};
in
{
# ── programs.hermes-agent — the installation ───────────────────────
# Home Manager separates "install this application for me" from "run
# this daemon". Hermes needs both, and a person can want one without
# the other: an application with no gateway, or a headless gateway on
# a machine with no display.
#
# `services.hermes-agent` stays the authority for the state and the
# configuration. This module reads hermesHome and the backend address
# from it, and never the reverse.
options.programs.hermes-agent = {
enable = lib.mkEnableOption ''
the Hermes Agent command line application.
This adds `hermes` to home.packages, and exports HERMES_HOME with
home.sessionVariables. An interactive shell then uses the same
state as `services.hermes-agent`
'';
package = lib.mkOption {
type = lib.types.package;
default = effectivePackage;
defaultText = lib.literalExpression "config.services.hermes-agent.package";
description = ''
The hermes-agent package to install.
The default follows `services.hermes-agent.package`, and applies
`extraPythonPackages` and `extraDependencyGroups` from that
module. Thus the command line and the services are one build,
and a plugin that the services can load is a plugin that your
shell can load.
'';
};
desktop = {
enable = lib.mkEnableOption ''
the Hermes Desktop application (Electron).
This adds `hermes-desktop` to home.packages, with an XDG
launcher entry on Linux. The launcher starts the same Hermes
runtime that `package` gives, and reads the HERMES_HOME of
`services.hermes-agent`. Thus the application, the interactive
shell and the services share one state directory.
The Electron application carries its own Hermes runtime with
the usual distribution. This module gives it the Nix package
instead, with HERMES_DESKTOP_HERMES. It installs no second copy
of Hermes, and it downloads nothing on the first start
'';
package = lib.mkOption {
type = lib.types.package;
default = cfgPrograms.package.hermesDesktop;
defaultText = lib.literalExpression "config.programs.hermes-agent.package.hermesDesktop";
description = ''
The hermes-desktop package to use.
The default follows `package`, and thus also
`services.hermes-agent.extraPythonPackages` and
`extraDependencyGroups`, because the desktop application is a
passthru of the agent package. A package that you set here
carries its own Hermes runtime, and this module cannot make
it agree with the services.
'';
};
};
};
options.services.hermes-agent =
common.sharedOptions {
defaultPackage = hermes-agent;
defaultPackageText = lib.literalExpression "hermes-agent.packages.\${system}.default";
defaultWorkingDirectory = config.home.homeDirectory;
defaultWorkingDirectoryText = lib.literalExpression "config.home.homeDirectory";
}
// {
hermesHome = lib.mkOption {
type = lib.types.str;
default = "${config.home.homeDirectory}/.hermes";
defaultText = lib.literalExpression ''"''${config.home.homeDirectory}/.hermes"'';
description = ''
The value of HERMES_HOME. This state directory holds
config.yaml, .env, auth.json, the sessions, the skills, the
memory and the cron jobs.
The NixOS module takes a `stateDir` and adds `/.hermes` to it.
This module sets HERMES_HOME directly. Thus an existing
~/.hermes continues to work, and you can give the directory any
name.
'';
example = "/home/alice/.hermes-work";
};
# `installPackage` moved to `programs.hermes-agent.enable`. The
# option is dead, but it must not be silent: it defaulted to true,
# so a person who never named it still got the command line, and a
# quiet removal gives them a machine with no `hermes` and no
# message. mkOption with an assertion, and not
# mkRemovedOptionModule, because the message must name the exact
# replacement for the value they set.
installPackage = lib.mkOption {
type = lib.types.nullOr lib.types.bool;
default = null;
visible = false;
description = ''
Removed. Use `programs.hermes-agent.enable` instead.
'';
};
gateway.enable = lib.mkEnableOption "the messaging gateway service (Telegram, Discord, Slack, ...)";
};
config = lib.mkMerge [
# ── programs.hermes-agent — the installation ──────────────────────
# Outside the `services.enable` guard on purpose. A person can want
# the command line or the application on a machine that runs no
# daemon at all.
(lib.mkIf cfgPrograms.enable {
home.packages = [ cfgPrograms.package ];
home.sessionVariables.HERMES_HOME = cfg.hermesHome;
})
# A launcher from the desktop menu reads no shell profile, so the
# HERMES_HOME that `programs.enable` exports does not reach it. Home
# Manager writes only systemd.user.sessionVariables into
# environment.d, and this module does not put HERMES_HOME there,
# because that file applies to each user unit. Thus the launcher
# carries the value itself. See desktopEnvironment above.
(lib.mkIf cfgPrograms.desktop.enable {
home.packages = [ desktopPackage ];
})
{
assertions = [
{
# `installPackage` was removed in favour of the programs/services
# split. It defaulted to true, so a quiet removal leaves a person
# with no `hermes` on the PATH and no message.
assertion = cfg.installPackage == null;
message = common.installPackageRemovedMessage cfg.installPackage;
}
];
}
(lib.mkIf cfg.enable (
lib.mkMerge [
# ── Merge MCP servers into settings ────────────────────────────
(lib.mkIf (cfg.mcpServers != { }) {
services.hermes-agent.settings.mcp_servers = common.mcpServersToConfig cfg.mcpServers;
})
{
assertions =
common.pluginNameAssertions {
inherit cfg;
optionPath = "services.hermes-agent";
}
++ common.workspaceFilesAssertions {
inherit cfg;
opt = options.services.hermes-agent.workingDirectory;
optionPath = "services.hermes-agent";
}
++ common.backendBindAssertions {
inherit cfg;
optionPath = "services.hermes-agent";
}
++ [
{
# The interface poll reads `ip`, which iproute2 supplies on
# Linux only.
assertion = !isDarwin || cfg.backend.waitFor != "interface";
message = "services.hermes-agent.backend.waitFor = \"interface\" works on Linux only. Use \"hostname\" on Darwin.";
}
];
}
# The agent runs these tools, so they belong on the PATH of the
# person as well as in the unit.
(lib.mkIf cfgPrograms.enable {
home.packages = cfg.extraPackages;
})
# ── Activation: directories, config, secrets, documents ────────
{
# The activation runs after writeBoundary, when the home.file
# symlinks are in place. It also runs after linkGeneration, when
# Home Manager completes the switch. A secret that the activation
# entry of sops-nix writes exists at that point.
home.activation.hermesAgentSetup =
lib.hm.dag.entryAfter
[
"writeBoundary"
"linkGeneration"
]
(
common.mkStateScript {
inherit pkgs cfg;
inherit (cfg) hermesHome workingDirectory;
run = "$DRY_RUN_CMD ";
stateDirs = common.stateSubdirs;
managedSystem = "home-manager";
# This state has one user. No group needs access to it.
modes = {
config = "0600";
env = "0600";
managed = "0600";
auth = "0600";
document = "0600";
};
}
);
}
# ── Linux: systemd user services ───────────────────────────────
(lib.mkIf (isLinux && cfg.gateway.enable) {
systemd.user.services.hermes-agent = mkUnit {
description = "Hermes Agent Gateway";
argv = common.gatewayArgv cfg;
};
})
(lib.mkIf (isLinux && cfg.backend.mode != "none") {
systemd.user.services.hermes-backend = mkUnit {
description = common.backendDescription cfg;
argv = common.backendArgv { inherit pkgs cfg; };
};
})
# ── Darwin: launchd agents ─────────────────────────────────────
(lib.mkIf (isDarwin && cfg.gateway.enable) {
launchd.agents.hermes-agent = mkAgent {
argv = common.gatewayArgv cfg;
logName = "hermes-agent";
};
})
(lib.mkIf (isDarwin && cfg.backend.mode != "none") {
launchd.agents.hermes-backend = mkAgent {
argv = common.backendArgv { inherit pkgs cfg; };
logName = "hermes-backend";
};
})
]
))
];
};
}
+352
View File
@@ -0,0 +1,352 @@
# nix/lib.nix — Shared helpers for nix stuff
#
# All npm packages in this repo are workspace members sharing a single
# root package-lock.json. mkNpmPassthru provides the shared npmDeps,
# npmRoot, and npmConfigHook so individual .nix files don't duplicate them.
#
# Source filters (pythonSrc, per-package npm srcs) reduce rebuild scope so
# that e.g. a .tsx change doesn't trigger a Python venv rebuild, and a .py
# change doesn't trigger a TUI/Web/Desktop rebuild. Each derivation gets a
# filtered src that only includes files it actually needs, while keeping
# the repo-root directory layout intact for buildNpmPackage /
# npmConfigHook workspace resolution.
#
# mkNpmPassthru returns packageJsonPath (e.g. "ui-tui/package.json")
# instead of a per-package devShellHook. The root devshell hook
# (mkNpmDevShellHook) collects all package.json paths, stamps them,
# and if any changed, runs a single `npm i --package-lock-only` from
# root to update the lockfile, then `npm ci` if the lockfile changed.
{
lib,
npm-lockfile-fix,
importNpmLock,
writeShellScriptBin,
writeShellScript,
coreutils,
callPackage,
nodejs_26,
symlinkJoin,
buildNpmPackage,
runCommand,
}:
let
repoRoot = ./..;
npm12 = callPackage ./npm-12-0-2.nix { };
node_gyp_11_4_0 = callPackage ./node-gyp-11-4-0.nix { };
nodejs_26_npm_12 = symlinkJoin {
name = "nodejs-26-npm-12";
paths = [
npm12
nodejs_26
];
inherit (nodejs_26) meta passthru;
};
nodejs = nodejs_26_npm_12;
# Patched hook: just a new derivation that copies and patches the script
patchedNpmConfigHook = runCommand "npm-config-hook-patched" { } ''
mkdir -p $out/nix-support
# Copy all support files from the original hook
cp -r ${importNpmLock.npmConfigHook}/nix-support/* $out/nix-support/
# Change the node gyp config var to avoid the warning with npm12
# Replace the node-gyp path with the newer one that supports the new config var
substituteInPlace $out/nix-support/setup-hook \
--replace-fail 'npm_config_nodedir' 'npm_package_config_node_gyp_nodedir' \
--replace-fail 'npm_config_node_gyp' 'npm_config_node_gyp=${node_gyp_11_4_0}/bin/node-gyp'
'';
# ── npm workspace discovery ────────────────────────────────────────
# Single source of truth: the `workspaces` field of the root
# package.json. Everything below (workspace package.json discovery,
# the Python source's JS-dir exclusions) is derived from this so the
# topology is never duplicated. Add a workspace to package.json and
# the nix build picks it up automatically.
rootPackageJson = builtins.fromJSON (builtins.readFile (repoRoot + "/package.json"));
# Expand a workspace glob (e.g. "apps/*") into concrete member dirs
# relative to the repo root. Only trailing "*" globs are supported —
# that's all npm uses here. Literal patterns (e.g. "ui-tui") pass
# through unchanged.
expandWorkspace =
pattern:
let
parts = lib.splitString "/" pattern;
in
if lib.last parts == "*" then
let
parent = lib.concatStringsSep "/" (lib.init parts);
entries = builtins.readDir (repoRoot + "/${parent}");
dirs = lib.filterAttrs (_: t: t == "directory") entries;
in
map (d: "${parent}/${d}") (builtins.attrNames dirs)
else
[ pattern ];
# All workspace member directories (relative paths), filtered to those
# that actually carry a package.json — a glob like apps/* may match a
# dir that isn't really a package.
workspaceMemberDirs = builtins.filter (d: builtins.pathExists (repoRoot + "/${d}/package.json")) (
lib.concatMap expandWorkspace rootPackageJson.workspaces
);
# Top-level directory of each workspace member, deduplicated. Used to
# exclude JS/TS workspace trees from the Python source filter. E.g.
# apps/desktop + apps/shared + ui-tui + web → [ "apps" "ui-tui" "web" ].
jsWorkspaceTopDirs = lib.unique (
map (d: builtins.head (lib.splitString "/" d)) workspaceMemberDirs
);
# ── Source filters for reducing rebuild scope ──────────────────────
# Changing a .tsx/.mjs file should NOT trigger a Python venv rebuild,
# and changing a .py file should NOT trigger a TUI/Web/Desktop rebuild.
# Python source: everything except JS/TS/docs/infra directories.
pythonSrc = lib.cleanSourceWith {
src = repoRoot;
name = "hermes-python-source";
filter =
path: type:
let
relPath = lib.removePrefix (toString repoRoot + "/") (toString path);
components = lib.splitString "/" relPath;
topComponent = if components == [ ] then "" else builtins.head components;
excludedDirs =
# JS/TS workspace directories — derived from the npm workspaces
# so a new workspace member is excluded from the Python source
# without touching this list.
jsWorkspaceTopDirs ++ [
# Documentation
"docs"
"website"
# CI/infra
"docker"
".github"
# Content/examples
"infographic"
"datagen-config-examples"
# unused packaging infra
"packaging"
# Test infrastructure
"tests"
# Plan/temp files
"plans"
# Nix build definitions (Python build doesn't need these)
"nix"
# Skills are shipped via HERMES_BUNDLED_SKILLS /
# HERMES_OPTIONAL_SKILLS (see hermes-agent.nix), not via the
# wheel's data_files — setup.py's _data_file_tree returns []
# for a missing dir, so the wheel builds fine without them.
# This keeps SKILL.md edits from rebuilding the Python venv.
"skills"
"optional-skills"
# locales/ and optional-mcps/ are bare data dirs (no
# __init__.py) shipped via symlinks + HERMES_BUNDLED_LOCALES
# / HERMES_OPTIONAL_MCPS, not via the wheel. Excluding them
# keeps catalog edits from rebuilding the Python venv.
"locales"
"optional-mcps"
];
excludedFiles = [
# JS root manifests
"package.json"
"package-lock.json"
# Docker files
"Dockerfile"
"docker-compose.yml"
"docker-compose.windows.yml"
# Nix build definitions — editing the flake shouldn't rebuild
# the venv. (Input changes rebuild regardless, via the lock.)
"flake.nix"
"flake.lock"
# Root docs the wheel doesn't consume. README.md and LICENSE
# must stay — pyproject.toml references them (readme /
# license-files).
"AGENTS.md"
"CONTRIBUTING.md"
"SECURITY.md"
"README.zh-CN.md"
".gitignore"
"setup-hermes.sh"
];
in
if relPath == "" then
true
else if builtins.elem relPath excludedFiles then
false
else if builtins.elem topComponent excludedDirs then
false
else
true;
};
# Common npm workspace resolution files needed by all npm builds.
# npm ci requires all workspace package.json files to resolve
# workspace: protocol dependencies correctly. Discovered from the
# root package.json workspaces — root manifests + every member's
# package.json.
npmWorkspaceFiles = lib.fileset.unions (
[
(repoRoot + "/package.json")
(repoRoot + "/package-lock.json")
]
++ map (d: repoRoot + "/${d}/package.json") workspaceMemberDirs
);
# npm deps source: just what importNpmLock needs (root manifests +
# workspace member package.jsons). Much smaller than the full repo,
# so changing source files won't invalidate the npmDeps derivation.
npmDepsSrc = lib.fileset.toSource {
root = repoRoot;
fileset = npmWorkspaceFiles;
};
# npm dependencies for the workspace, shared by all members. importNpmLock
# resolves each package from the lockfile's own `integrity` hashes, so the
# lockfile is the single source of truth — no separate dependency hash to
# keep in sync with it.
npmDeps = importNpmLock.importNpmLock {
npmRoot = npmDepsSrc;
};
# Build a per-package npm source: workspace resolution files + the
# package's own directory tree(s). Source ROOT is always the repo
# root, preserving the workspace layout that buildNpmPackage and
# npmConfigHook expect. Callers pass the dirs they need (relative to
# the repo root), so each package owns its own source scope.
testFileFilter = lib.fileset.fileFilter (file: lib.hasInfix ".test." file.name) repoRoot;
mkNpmSrc =
dirs:
lib.fileset.toSource {
root = repoRoot;
fileset = lib.fileset.difference (lib.fileset.union npmWorkspaceFiles (
lib.fileset.unions (map (d: repoRoot + "/${d}") dirs)
)) testFileFilter;
};
# Returns a buildNpmPackage-compatible function.
# `dirs` is the single source of truth for what the package contains:
# its first entry is the package's own folder (→ packageJsonPath), and
# all entries scope the filtered src. Packages that import source from
# another workspace member (file: deps) must list that member's dir too,
# e.g. apps/desktop depends on apps/shared.
#
# Usage:
# hermesNpmLib.buildNpmPackage {
# dirs = [ "apps/desktop" "apps/shared" ];
# buildPhase = '' ... '';
# installPhase = '' ... '';
# }
customBuildNpmPackage =
{ dirs, ... }@attrs:
let
# The package's own folder is the first dir; it carries the
# package.json that buildNpmPackage reads.
folder = builtins.head dirs;
# Read package.json from the repo (the filtered src is a store path, but we can read the original)
packageJson = builtins.fromJSON (builtins.readFile (repoRoot + "/${folder}/package.json"));
defaultPname = packageJson.name or "unknown";
defaultVersion = packageJson.version or "0.0.0";
common = {
inherit nodejs npmDeps;
# No sourceRoot — the workspace root (with the single package-lock.json)
# is auto-detected as sourceRoot by nix. npmRoot stays at "."
# so npmConfigHook finds the lockfile there.
src = mkNpmSrc dirs;
npmConfigHook = patchedNpmConfigHook;
npmRoot = ".";
ELECTRON_SKIP_BINARY_DOWNLOAD = 1;
passthru = {
packageJsonPath = "${folder}/package.json";
};
};
# Remove `dirs` from the passed attrs (buildNpmPackage doesn't need it)
attrsWithoutDirs = removeAttrs attrs [ "dirs" ];
finalAttrs =
common
// attrsWithoutDirs
// {
pname = attrs.pname or defaultPname;
version = attrs.version or defaultVersion;
};
in
buildNpmPackage finalAttrs;
in
{
inherit pythonSrc nodejs;
node-gyp = node_gyp_11_4_0;
# Regenerate the shared root lockfile from scratch and verify all npm
# packages still build. Exposed as a runnable package — `nix run
# .#update-npm-lockfile` — so it's actually usable, unlike a bin buried
# in a build sandbox's PATH. All workspace packages share one lockfile,
# so there's a single script (not one per package).
updateNpmLockfile = writeShellScriptBin "update-npm-lockfile" ''
set -euo pipefail
# DEBUG=1 nix run .#update-npm-lockfile trace every command
[ -n "''${DEBUG:-}" ] && set -x
REPO_ROOT=$(git rev-parse --show-toplevel)
cd "$REPO_ROOT"
rm -rf node_modules/
${lib.getExe' nodejs "npm"} cache clean --force
CI=true ${lib.getExe' nodejs "npm"} install --workspaces
${lib.getExe npm-lockfile-fix} ./package-lock.json
# importNpmLock reads hashes from the lockfile itself rebuild every
# npm package to verify the new lockfile resolves offline.
nix build .#tui .#web .#desktop
echo "Lockfile updated and all npm packages built."
'';
buildNpmPackage = customBuildNpmPackage;
# Single devshell hook for all npm workspace packages.
#
# Takes a list of package.json relative paths (from mkNpmPassthru .passthru.packageJsonPath),
# stamps all of them, and if any changed:
# 1. Runs `npm i --package-lock-only` from root to update the lockfile
# 2. If the lockfile changed, runs `npm ci`
mkNpmDevShellHook =
packageJsonPaths:
writeShellScript "npm-dev-hook" ''
REPO_ROOT=$(git rev-parse --show-toplevel)
# Stamp all workspace package.jsons into one file.
STAMP_DIR=".nix-stamps"
STAMP="$STAMP_DIR/npm-package-jsons"
STAMP_VALUE=$(
${coreutils}/bin/sha256sum ${
lib.concatMapStringsSep " " (p: "\"$REPO_ROOT/${p}\"") packageJsonPaths
} 2>/dev/null | ${coreutils}/bin/sort | ${coreutils}/bin/sha256sum | awk '{print $1}'
)
PKG_CHANGED=false
if [ ! -f "$STAMP" ] || [ "$(cat "$STAMP")" != "$STAMP_VALUE" ]; then
PKG_CHANGED=true
echo "npm: package.json changed, updating lockfile..."
( cd "$REPO_ROOT" && ${lib.getExe' nodejs "npm"} i --package-lock-only --silent --no-fund --no-audit 2>/dev/null )
mkdir -p "$STAMP_DIR"
echo "$STAMP_VALUE" > "$STAMP"
fi
# Check if lockfile changed (either from the npm i above or from an
# external edit). Runs npm ci if so.
LOCK_STAMP="$STAMP_DIR/root-lockfile"
LOCK_STAMP_VALUE=$(sha256sum "$REPO_ROOT/package-lock.json" 2>/dev/null | awk '{print $1}')
if [ ! -f "$LOCK_STAMP" ] || [ "$(cat "$LOCK_STAMP")" != "$LOCK_STAMP_VALUE" ]; then
echo "npm: package-lock.json changed, running npm ci..."
( cd "$REPO_ROOT" && CI=true ${lib.getExe' nodejs "npm"} ci --silent --no-fund --no-audit 2>/dev/null )
mkdir -p "$STAMP_DIR"
echo "$LOCK_STAMP_VALUE" > "$LOCK_STAMP"
fi
'';
}
+1165
View File
File diff suppressed because it is too large Load Diff
+670
View File
@@ -0,0 +1,670 @@
# nix/nixosModules.nix — the NixOS module for hermes-agent
#
# This module shares its options, its renderers for config.yaml, .env and
# documents, and its state setup with the Home Manager module
# (nix/homeManagerModules.nix). The shared code is in nix/moduleCommon.nix.
# This file holds only the parts that need root: the service user, a system
# state directory, the system PATH, and container mode.
#
# Two modes:
# container.enable = false (default) → native systemd service
# container.enable = true → OCI container (persistent writable layer)
#
# Container mode: hermes runs from /nix/store bind-mounted read-only into a
# plain Ubuntu container. The writable layer (apt/pip/npm installs) persists
# across restarts and agent updates. Only image/volume/options changes trigger
# container recreation. Environment variables are written to $HERMES_HOME/.env
# and read by hermes at startup — no container recreation needed for env changes.
#
# Tool resolution: the hermes wrapper uses --suffix PATH for nix store tools,
# so apt/uv-installed versions take priority. The container entrypoint provisions
# extensible tools on first boot: nodejs/npm via apt, uv via curl, and a Python
# 3.11 venv (bootstrapped entirely by uv) at ~/.venv with pip seeded. Agents get
# writable tool prefixes for npm i -g, pip install, uv tool install, etc.
#
# Usage:
# services.hermes-agent = {
# enable = true;
# settings.model.default = "anthropic/claude-sonnet-4";
# environmentFiles = [ config.sops.secrets."hermes/env".path ];
# };
#
{ inputs, ... }:
{
flake.nixosModules.default =
{
config,
lib,
options,
pkgs,
...
}:
let
cfg = config.services.hermes-agent;
common = import ./moduleCommon.nix { inherit lib; };
effectivePackage = common.effectivePackage cfg;
hermes-agent = inputs.self.packages.${pkgs.stdenv.hostPlatform.system}.default;
hermesHome = "${cfg.stateDir}/.hermes";
# In container mode, the agent uses the mount path in the container.
effectiveWorkDir = if cfg.container.enable then containerWorkDir else cfg.workingDirectory;
# config.yaml mode: group-writable (0660) when interactive users share this
# HERMES_HOME via addToSystemPackages, so they can save settings through the
# CLI/TUI without hitting EACCES; otherwise group-read-only (0640). Secrets
# (.env) stay 0640 regardless.
configYamlMode = if cfg.addToSystemPackages then "0660" else "0640";
containerName = "hermes-agent";
containerDataDir = "/data"; # stateDir mount point inside container
containerHomeDir = "/home/hermes";
# ── Container mode helpers ──────────────────────────────────────────
containerBin =
if cfg.container.backend == "docker" then
"${pkgs.docker}/bin/docker"
else
"${pkgs.podman}/bin/podman";
# Runs as root inside the container on every start. Provisions the
# hermes user + sudo on first boot (writable layer persists), then
# drops privileges. Supports arbitrary base images (Debian, Alpine, etc).
containerEntrypoint = pkgs.writeShellScript "hermes-container-entrypoint" ''
set -eu
HERMES_UID="''${HERMES_UID:?HERMES_UID must be set}"
HERMES_GID="''${HERMES_GID:?HERMES_GID must be set}"
# Group: ensure a group with GID=$HERMES_GID exists
# Check by GID (not name) to avoid collisions with pre-existing groups
# (e.g. GID 100 = "users" on Ubuntu)
EXISTING_GROUP=$(getent group "$HERMES_GID" 2>/dev/null | cut -d: -f1 || true)
if [ -n "$EXISTING_GROUP" ]; then
GROUP_NAME="$EXISTING_GROUP"
else
GROUP_NAME="hermes"
if command -v groupadd >/dev/null 2>&1; then
groupadd -g "$HERMES_GID" "$GROUP_NAME"
elif command -v addgroup >/dev/null 2>&1; then
addgroup -g "$HERMES_GID" "$GROUP_NAME" 2>/dev/null || true
fi
fi
# User: ensure a user with UID=$HERMES_UID exists
PASSWD_ENTRY=$(getent passwd "$HERMES_UID" 2>/dev/null || true)
if [ -n "$PASSWD_ENTRY" ]; then
TARGET_USER=$(echo "$PASSWD_ENTRY" | cut -d: -f1)
TARGET_HOME=$(echo "$PASSWD_ENTRY" | cut -d: -f6)
else
TARGET_USER="hermes"
TARGET_HOME="/home/hermes"
if command -v useradd >/dev/null 2>&1; then
useradd -u "$HERMES_UID" -g "$HERMES_GID" -m -d "$TARGET_HOME" -s /bin/bash "$TARGET_USER"
elif command -v adduser >/dev/null 2>&1; then
adduser -u "$HERMES_UID" -D -h "$TARGET_HOME" -s /bin/sh -G "$GROUP_NAME" "$TARGET_USER" 2>/dev/null || true
fi
fi
mkdir -p "$TARGET_HOME"
chown "$HERMES_UID:$HERMES_GID" "$TARGET_HOME"
chmod 0750 "$TARGET_HOME"
# Ensure HERMES_HOME is owned by the target user.
# Use find instead of chown -R: chown strips the setgid bit (kernel
# behavior), destroying the 2770 permissions the NixOS activation
# script sets for group access by hostUsers. Only touch files with
# wrong ownership so correctly-owned dirs keep their permission bits.
if [ -n "''${HERMES_HOME:-}" ] && [ -d "$HERMES_HOME" ]; then
find "$HERMES_HOME" \! -user "$HERMES_UID" -exec chown "$HERMES_UID:$HERMES_GID" {} +
fi
# Provision apt packages (first boot only, cached in writable layer)
# sudo: agent self-modification
# nodejs/npm: writable node so npm i -g works (nix store copies are read-only)
# Node 22 via NodeSource Ubuntu 24.04 ships Node 18 which is EOL.
# curl: needed for uv installer + NodeSource setup
if [ ! -f /var/lib/hermes-tools-provisioned ] && command -v apt-get >/dev/null 2>&1; then
echo "First boot: provisioning agent tools..."
apt-get update -qq
apt-get install -y -qq sudo curl ca-certificates gnupg
mkdir -p /etc/apt/keyrings
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \
| gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg
echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_22.x nodistro main" \
> /etc/apt/sources.list.d/nodesource.list
apt-get update -qq
apt-get install -y -qq nodejs
touch /var/lib/hermes-tools-provisioned
fi
if command -v sudo >/dev/null 2>&1 && [ ! -f /etc/sudoers.d/hermes ]; then
mkdir -p /etc/sudoers.d
echo "$TARGET_USER ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/hermes
chmod 0440 /etc/sudoers.d/hermes
fi
# uv (Python manager) not in Ubuntu repos, retry-safe outside the sentinel
if ! command -v uv >/dev/null 2>&1 && [ ! -x "$TARGET_HOME/.local/bin/uv" ] && command -v curl >/dev/null 2>&1; then
su -s /bin/sh "$TARGET_USER" -c 'curl -LsSf https://astral.sh/uv/install.sh | sh' || true
fi
# Python 3.12 venv gives the agent a writable Python with pip.
# --seed includes pip/setuptools so bare `pip install` works.
_UV_BIN="$TARGET_HOME/.local/bin/uv"
if [ ! -d "$TARGET_HOME/.venv" ] && [ -x "$_UV_BIN" ]; then
su -s /bin/sh "$TARGET_USER" -c "
export PATH=\"\$HOME/.local/bin:\$PATH\"
uv python install 3.12
uv venv --python 3.12 --seed \"\$HOME/.venv\"
" || true
fi
# Put the agent venv first on PATH so python/pip resolve to writable copies
if [ -d "$TARGET_HOME/.venv/bin" ]; then
export PATH="$TARGET_HOME/.venv/bin:$PATH"
fi
if command -v setpriv >/dev/null 2>&1; then
exec setpriv --reuid="$HERMES_UID" --regid="$HERMES_GID" --init-groups "$@"
elif command -v su >/dev/null 2>&1; then
exec su -s /bin/sh "$TARGET_USER" -c 'exec "$0" "$@"' -- "$@"
else
echo "WARNING: no privilege-drop tool (setpriv/su), running as root" >&2
exec "$@"
fi
'';
# Identity hash — only recreate container when structural config changes.
# Package and entrypoint use stable symlinks (current-package, current-entrypoint)
# so they can update without recreation. Env vars go through $HERMES_HOME/.env.
containerIdentity = builtins.hashString "sha256" (
builtins.toJSON {
schema = 4; # bump when identity inputs change (4: Node 18→22 via NodeSource)
image = cfg.container.image;
extraVolumes = cfg.container.extraVolumes;
extraOptions = cfg.container.extraOptions;
}
);
identityFile = "${cfg.stateDir}/.container-identity";
# The CLI on the host reads this file, in get_container_exec_info. The
# file tells the CLI to run in the container and not on the host.
containerModeFile = pkgs.writeText "hermes-container-mode" ''
# Written by the NixOS activation script. Do not edit manually.
backend=${cfg.container.backend}
container_name=${containerName}
exec_user=${cfg.user}
hermes_bin=${containerDataDir}/current-package/bin/hermes
'';
# Default: /var/lib/hermes/workspace → /data/workspace.
# Custom paths outside stateDir pass through unchanged (user must add extraVolumes).
containerWorkDir =
if lib.hasPrefix "${cfg.stateDir}/" cfg.workingDirectory then
"${containerDataDir}/${lib.removePrefix "${cfg.stateDir}/" cfg.workingDirectory}"
else
cfg.workingDirectory;
# The hardening and the environment that the gateway unit and the
# backend unit share.
commonServiceConfig = {
User = cfg.user;
Group = cfg.group;
WorkingDirectory = cfg.workingDirectory;
Restart = cfg.restart;
RestartSec = cfg.restartSec;
# Shared-state: files created by the service should be group-writable
# so interactive users in the hermes group can read/write them.
UMask = "0007";
# Hardening
NoNewPrivileges = true;
ProtectSystem = "strict";
ProtectHome = false;
ReadWritePaths = [
cfg.stateDir
cfg.workingDirectory
];
PrivateTmp = true;
};
commonUnitEnvironment = {
HOME = cfg.stateDir;
}
// common.processEnvironment { inherit hermesHome; };
unitPath = common.processPath { inherit pkgs cfg; };
in
{
options.services.hermes-agent =
common.sharedOptions {
defaultPackage = hermes-agent;
defaultPackageText = lib.literalExpression "hermes-agent.packages.\${system}.default";
defaultWorkingDirectory = "${cfg.stateDir}/workspace";
defaultWorkingDirectoryText = lib.literalExpression ''"''${cfg.stateDir}/workspace"'';
}
// (
with lib;
{
# ── Service identity ───────────────────────────────────────────
user = mkOption {
type = types.str;
default = "hermes";
description = "System user running the gateway.";
};
group = mkOption {
type = types.str;
default = "hermes";
description = "System group running the gateway.";
};
createUser = mkOption {
type = types.bool;
default = true;
description = "Create the user/group automatically.";
};
# ── Directories ────────────────────────────────────────────────
stateDir = mkOption {
type = types.str;
default = "/var/lib/hermes";
description = "State directory. Contains .hermes/ subdir (HERMES_HOME).";
};
addToSystemPackages = mkOption {
type = types.bool;
default = false;
description = ''
Add the hermes CLI to environment.systemPackages and export
HERMES_HOME system-wide (via environment.variables) so interactive
shells share state with the gateway service.
'';
};
# ── OCI Container (opt-in) ────────────────────────────────────
container = {
enable = mkEnableOption "OCI container mode (Ubuntu base, full self-modification support)";
backend = mkOption {
type = types.enum [
"docker"
"podman"
];
default = "docker";
description = "Container runtime.";
};
extraVolumes = mkOption {
type = types.listOf types.str;
default = [ ];
description = "Extra volume mounts (host:container:mode format).";
example = [ "/home/user/projects:/projects:rw" ];
};
extraOptions = mkOption {
type = types.listOf types.str;
default = [ ];
description = "Extra arguments passed to docker/podman run.";
};
image = mkOption {
type = types.str;
default = "ubuntu:24.04";
description = "OCI container image. The container pulls this at runtime via Docker/Podman.";
};
hostUsers = mkOption {
type = types.listOf types.str;
default = [ ];
description = ''
Interactive users who get a ~/.hermes symlink to the service
stateDir. These users are automatically added to the hermes group.
'';
example = [ "sidbin" ];
};
};
}
);
config = lib.mkIf cfg.enable (
lib.mkMerge [
# ── Merge MCP servers into settings ────────────────────────────────
(lib.mkIf (cfg.mcpServers != { }) {
services.hermes-agent.settings.mcp_servers = common.mcpServersToConfig cfg.mcpServers;
})
# ── User / group ──────────────────────────────────────────────────
(lib.mkIf cfg.createUser {
users.groups.${cfg.group} = { };
users.users.${cfg.user} = {
isSystemUser = true;
group = cfg.group;
home = cfg.stateDir;
createHome = true;
shell = pkgs.bashInteractive;
};
})
# ── Host CLI ──────────────────────────────────────────────────────
# Add the hermes CLI to system PATH and export HERMES_HOME system-wide
# so interactive shells share state (sessions, skills, cron) with the
# gateway service instead of creating a separate ~/.hermes/.
(lib.mkIf cfg.addToSystemPackages {
environment.systemPackages = [ effectivePackage ];
environment.variables.HERMES_HOME = hermesHome;
})
# ── Host user group membership ─────────────────────────────────────
(lib.mkIf (cfg.container.enable && cfg.container.hostUsers != [ ]) {
users.users = lib.genAttrs cfg.container.hostUsers (_user: {
extraGroups = [ cfg.group ];
});
})
# ── Assertions ─────────────────────────────────────────────────────
{
assertions =
common.pluginNameAssertions {
inherit cfg;
optionPath = "services.hermes-agent";
}
++ common.workspaceFilesAssertions {
inherit cfg;
opt = options.services.hermes-agent.workingDirectory;
optionPath = "services.hermes-agent";
}
++ common.backendBindAssertions {
inherit cfg;
optionPath = "services.hermes-agent";
}
++ [
{
# Container mode runs one command in one container. A second
# process needs its own container and its own ports. This
# module does not do that.
assertion = !(cfg.container.enable && cfg.backend.mode != "none");
message = "services.hermes-agent: backend.mode is not supported together with container.enable the container runs the gateway only.";
}
];
}
# ── Per-user profile for extraPackages ───────────────────────────
# Wire extraPackages into the hermes user's per-user profile so the
# login-shell snapshot (which rebuilds PATH from NixOS profiles) sees
# them. The systemd service PATH also includes them for direct access.
(lib.mkIf (cfg.extraPackages != [ ]) {
# listOf options are merged by the NixOS module system — this appends to
# any packages the operator assigned to this user externally (e.g. when
# createUser = false and the user definition lives elsewhere in the config).
users.users.${cfg.user}.packages = cfg.extraPackages;
})
# ── Warnings ──────────────────────────────────────────────────────
(lib.mkIf
(cfg.container.enable && !cfg.addToSystemPackages && cfg.container.hostUsers != [ ])
{
warnings = [
''
services.hermes-agent: container.enable is true and container.hostUsers
is set, but addToSystemPackages is false. Without a host-installed hermes
binary, container routing will not work for interactive users.
Set addToSystemPackages = true or ensure hermes is on PATH.
''
];
}
)
# ── Directories ───────────────────────────────────────────────────
{
systemd.tmpfiles.rules = [
"d ${cfg.stateDir} 2770 ${cfg.user} ${cfg.group} - -"
"d ${hermesHome} 2770 ${cfg.user} ${cfg.group} - -"
"d ${cfg.stateDir}/home 0750 ${cfg.user} ${cfg.group} - -"
"d ${cfg.workingDirectory} 2770 ${cfg.user} ${cfg.group} - -"
]
++ map (d: "d ${hermesHome}/${d} 2770 ${cfg.user} ${cfg.group} - -") common.stateSubdirs;
}
# ── Activation: link config + auth + documents ────────────────────
{
system.activationScripts."hermes-agent-setup" =
lib.stringAfter
(
[ "users" ] ++ lib.optional (config.system.activationScripts ? setupSecrets) "setupSecrets"
)
''
# Ensure directories exist (activation runs before tmpfiles)
mkdir -p ${hermesHome}
mkdir -p ${cfg.stateDir}/home
mkdir -p ${cfg.workingDirectory}
chown ${cfg.user}:${cfg.group} ${cfg.stateDir} ${hermesHome} ${cfg.stateDir}/home ${cfg.workingDirectory}
chmod 2770 ${cfg.stateDir} ${hermesHome} ${cfg.workingDirectory}
chmod 0750 ${cfg.stateDir}/home
# Create subdirs, set setgid + group-writable, migrate existing files.
# Nix-managed .env/.managed stay 0640/0644; config.yaml uses
# configYamlMode (0660 under addToSystemPackages, else 0640).
find ${hermesHome} -maxdepth 1 \
\( -name "*.db" -o -name "*.db-wal" -o -name "*.db-shm" -o -name "SOUL.md" \) \
-exec chmod g+rw {} + 2>/dev/null || true
for _subdir in ${lib.concatStringsSep " " common.stateSubdirs}; do
mkdir -p "${hermesHome}/$_subdir"
chown ${cfg.user}:${cfg.group} "${hermesHome}/$_subdir"
chmod 2770 "${hermesHome}/$_subdir"
find "${hermesHome}/$_subdir" -type f \
-exec chmod g+rw {} + 2>/dev/null || true
done
${common.mkStateScript {
inherit pkgs cfg hermesHome;
workingDirectory = cfg.workingDirectory;
configWorkingDirectory = effectiveWorkDir;
owner = "${cfg.user}:${cfg.group}";
stateDirs = common.stateSubdirs;
modes = {
config = configYamlMode;
env = "0640";
managed = "0644";
auth = "0600";
document = "0640";
};
}}
chown -h ${cfg.user}:${cfg.group} ${hermesHome}/plugins/nix-managed-* 2>/dev/null || true
# Container mode metadata tells the host CLI to exec into the
# container instead of running locally. Removed when container mode
# is disabled so the host CLI falls back to native execution.
${
if cfg.container.enable then
''
install -o ${cfg.user} -g ${cfg.group} -m 0644 ${containerModeFile} ${hermesHome}/.container-mode
''
else
''
rm -f ${hermesHome}/.container-mode
# Remove symlink bridge for hostUsers
${lib.concatStringsSep "\n" (
map (
user:
let
userHome = config.users.users.${user}.home;
symlinkPath = "${userHome}/.hermes";
in
''
if [ -L "${symlinkPath}" ] && [ "$(readlink "${symlinkPath}")" = "${hermesHome}" ]; then
rm -f "${symlinkPath}"
echo "hermes-agent: removed symlink ${symlinkPath}"
fi
''
) cfg.container.hostUsers
)}
''
}
# Symlink bridge for interactive users
# Create ~/.hermes -> stateDir/.hermes for each hostUser so the
# host CLI shares state with the container service.
# Only runs when container mode is enabled.
${lib.optionalString cfg.container.enable (
lib.concatStringsSep "\n" (
map (
user:
let
userHome = config.users.users.${user}.home;
symlinkPath = "${userHome}/.hermes";
in
''
if [ -d "${symlinkPath}" ] && [ ! -L "${symlinkPath}" ]; then
# Real directory back it up, then create symlink.
# (ln -sfn cannot atomically replace a directory.)
_backup="${symlinkPath}.bak.$(date +%s)"
echo "hermes-agent: backing up existing ${symlinkPath} to $_backup"
mv "${symlinkPath}" "$_backup"
fi
# For everything else (existing symlink, doesn't exist, etc.)
# ln -sfn handles it: replaces symlinks, creates new ones.
ln -sfn "${hermesHome}" "${symlinkPath}"
chown -h ${user}:${cfg.group} "${symlinkPath}"
''
) cfg.container.hostUsers
)
)}
'';
}
# ══════════════════════════════════════════════════════════════════
# MODE A: Native systemd service (default)
# ══════════════════════════════════════════════════════════════════
(lib.mkIf (!cfg.container.enable) {
systemd.services.hermes-agent = {
description = "Hermes Agent Gateway";
wantedBy = [ "multi-user.target" ];
after = [ "network-online.target" ];
wants = [ "network-online.target" ];
# cfg.environment and cfg.environmentFiles are written to
# $HERMES_HOME/.env by the activation script. load_hermes_dotenv()
# reads them at Python startup — no systemd EnvironmentFile needed.
environment = commonUnitEnvironment;
serviceConfig = commonServiceConfig // {
ExecStart = lib.escapeShellArgs (common.gatewayArgv cfg);
};
path = unitPath;
};
})
# ── The backend: hermes serve or hermes dashboard ─────────────────
# This is a different process from the gateway. Both use one
# HERMES_HOME.
(lib.mkIf (!cfg.container.enable && cfg.backend.mode != "none") {
systemd.services.hermes-backend = {
description = common.backendDescription cfg;
wantedBy = [ "multi-user.target" ];
after = [ "network-online.target" ];
wants = [ "network-online.target" ];
environment = commonUnitEnvironment;
serviceConfig = commonServiceConfig // {
ExecStart = lib.escapeShellArgs (common.backendArgv { inherit pkgs cfg; });
};
path = unitPath;
};
})
# ══════════════════════════════════════════════════════════════════
# MODE B: OCI container (persistent writable layer)
# ══════════════════════════════════════════════════════════════════
(lib.mkIf cfg.container.enable {
# Ensure the container runtime is available
virtualisation.docker.enable = lib.mkDefault (cfg.container.backend == "docker");
systemd.services.hermes-agent = {
description = "Hermes Agent Gateway (container)";
wantedBy = [ "multi-user.target" ];
after = [
"network-online.target"
]
++ lib.optional (cfg.container.backend == "docker") "docker.service";
wants = [ "network-online.target" ];
requires = lib.optional (cfg.container.backend == "docker") "docker.service";
preStart = ''
# Stable symlinks container references these, not store paths directly
ln -sfn ${effectivePackage} ${cfg.stateDir}/current-package
ln -sfn ${containerEntrypoint} ${cfg.stateDir}/current-entrypoint
# GC roots so nix-collect-garbage doesn't remove store paths in use
${pkgs.nix}/bin/nix-store --add-root ${cfg.stateDir}/.gc-root --indirect -r ${effectivePackage} 2>/dev/null || true
${pkgs.nix}/bin/nix-store --add-root ${cfg.stateDir}/.gc-root-entrypoint --indirect -r ${containerEntrypoint} 2>/dev/null || true
# Check if container needs (re)creation
NEED_CREATE=false
if ! ${containerBin} inspect ${containerName} &>/dev/null; then
NEED_CREATE=true
elif [ ! -f ${identityFile} ] || [ "$(cat ${identityFile})" != "${containerIdentity}" ]; then
echo "Container config changed, recreating..."
${containerBin} rm -f ${containerName} || true
NEED_CREATE=true
fi
if [ "$NEED_CREATE" = "true" ]; then
# Resolve numeric UID/GID passed to entrypoint for in-container user setup
HERMES_UID=$(${pkgs.coreutils}/bin/id -u ${cfg.user})
HERMES_GID=$(${pkgs.coreutils}/bin/id -g ${cfg.user})
echo "Creating container..."
${containerBin} create \
--name ${containerName} \
--network=host \
--entrypoint ${containerDataDir}/current-entrypoint \
--volume /nix/store:/nix/store:ro \
--volume ${cfg.stateDir}:${containerDataDir} \
--volume ${cfg.stateDir}/home:${containerHomeDir} \
${lib.concatStringsSep " " (map (v: "--volume ${v}") cfg.container.extraVolumes)} \
--env HERMES_UID="$HERMES_UID" \
--env HERMES_GID="$HERMES_GID" \
--env HERMES_HOME=${containerDataDir}/.hermes \
--env HERMES_MANAGED=true \
--env HOME=${containerHomeDir} \
${lib.concatStringsSep " " cfg.container.extraOptions} \
${cfg.container.image} \
${containerDataDir}/current-package/bin/hermes gateway run --replace ${lib.concatStringsSep " " cfg.extraArgs}
echo "${containerIdentity}" > ${identityFile}
fi
'';
script = ''
exec ${containerBin} start -a ${containerName}
'';
preStop = ''
${containerBin} stop -t 10 ${containerName} || true
'';
serviceConfig = {
Type = "simple";
Restart = cfg.restart;
RestartSec = cfg.restartSec;
TimeoutStopSec = 30;
};
};
})
]
);
};
}
File diff suppressed because it is too large Load Diff
+40
View File
@@ -0,0 +1,40 @@
{
buildNpmPackage,
fetchFromGitHub,
nodejs,
lib,
}:
let
node-gyp-11_4_0 = buildNpmPackage rec {
pname = "node-gyp";
version = "11.4.0";
src = fetchFromGitHub {
owner = "nodejs";
repo = "node-gyp";
rev = "refs/tags/v${version}";
hash = "sha256-VtomUV+0kTp34IuS0D0dR4ZMWpk4Ptpk1CBP8rdW2a4=";
};
postPatch = ''
ln -s ${./node-gyp-11-4-0-package-lock.json} package-lock.json
'';
npmDepsHash = "sha256-P25m02VxIXkPD7rYI2Wki9+levrtNg2xk8pU+nEUXsE=";
npmDepsFetcherVersion = 2;
dontNpmBuild = true;
makeWrapperArgs = [ "--set npm_config_nodedir ${nodejs}" ];
meta = {
description = "Node.js native addon build tool";
homepage = "https://github.com/nodejs/node-gyp";
license = lib.licenses.mit;
mainProgram = "node-gyp";
};
};
in
node-gyp-11_4_0
+29
View File
@@ -0,0 +1,29 @@
{
stdenv,
makeWrapper,
fetchurl,
nodejs_26,
}:
stdenv.mkDerivation rec {
pname = "npm";
version = "12.0.2";
src = fetchurl {
url = "https://registry.npmjs.org/npm/-/npm-${version}.tgz";
hash = "sha256-XbuGxx0HoZV/LpBzQJLdali9zZ68LY1ByhxuaiHTZOE=";
};
nativeBuildInputs = [ makeWrapper ];
dontBuild = true;
installPhase = ''
mkdir -p $out/lib/npm12
cp -r . $out/lib/npm12/
mkdir -p $out/bin
makeWrapper ${nodejs_26}/bin/node $out/bin/npm \
--add-flags "$out/lib/npm12/bin/npm-cli.js"
makeWrapper ${nodejs_26}/bin/node $out/bin/npx \
--add-flags "$out/lib/npm12/bin/npx-cli.js"
'';
}
+14
View File
@@ -0,0 +1,14 @@
# nix/overlays.nix — Expose pkgs.hermes-agent for external NixOS configs
#
# The overlay is a pure alias for this flake's own package — NOT a
# re-instantiation against the consumer's nixpkgs. This guarantees
# `pkgs.hermes-agent`, `nix build .#default`, and the NixOS module's
# default package are all the exact same locked, tested derivation.
# (.override { extraPythonPackages = ...; } still works — callPackage's
# makeOverridable travels with the package.)
{ inputs, ... }:
{
flake.overlays.default = final: _: {
hermes-agent = inputs.self.packages.${final.stdenv.hostPlatform.system}.default;
};
}
+75
View File
@@ -0,0 +1,75 @@
# nix/packages.nix — Hermes Agent package built with uv2nix
{ inputs, ... }:
{
perSystem =
{
pkgs,
lib,
inputs',
...
}:
let
sandbox = pkgs.callPackage ./sandbox.nix { };
minimal = pkgs.callPackage ./hermes-agent.nix {
inherit (inputs) uv2nix pyproject-nix pyproject-build-systems;
npm-lockfile-fix = inputs'.npm-lockfile-fix.packages.default;
# Only embed clean revs — dirtyRev doesn't represent any upstream
# commit, so comparing it would always claim "update available".
rev = inputs.self.rev or null;
};
# All platform-portable optional integrations pre-built.
full = minimal.override {
extraDependencyGroups = [
"anthropic"
"azure-identity"
"bedrock"
"daytona"
"dingtalk"
"edge-tts"
"exa"
"fal"
"feishu"
"firecrawl"
"hindsight"
"honcho"
"messaging"
"modal"
"parallel-web"
"tts-premium"
"vercel"
"voice"
]
# matrix is Linux-only (oqs/liboqs lacks aarch64-darwin wheels).
++ lib.optionals pkgs.stdenv.isLinux [ "matrix" ];
};
in
{
packages = {
node-gyp =
(pkgs.callPackage ./lib.nix {
inherit (pkgs) npm-lockfile-fix;
}).node-gyp;
default = full;
inherit sandbox;
inherit minimal;
# Ships discord.py + python-telegram-bot + slack-sdk so a plain
# `nix profile install .#messaging` connects to Discord/Telegram/Slack
# on first run — lazy-install can't write to the read-only /nix/store.
messaging = minimal.override {
extraDependencyGroups = [ "messaging" ];
};
tui = full.hermesTui;
web = full.hermesWeb;
desktop = full.hermesDesktop;
update-npm-lockfile = full.hermesNpmLib.updateNpmLockfile;
};
};
}
+157
View File
@@ -0,0 +1,157 @@
# nix/python.nix — uv2nix virtual environment builder
{
python312,
lib,
callPackage,
uv2nix,
pyproject-nix,
pyproject-build-systems,
stdenv,
# Filtered Python source (see lib.nix pythonSrc) — keeps JS/docs/skills
# edits from invalidating the venv derivation.
pythonSrc,
dependency-groups ? [ "all" ],
}:
let
workspace = uv2nix.lib.workspace.loadWorkspace { workspaceRoot = pythonSrc; };
hacks = callPackage pyproject-nix.build.hacks { };
overlay = workspace.mkPyprojectOverlay {
sourcePreference = "wheel";
};
isAarch64Darwin = stdenv.hostPlatform.system == "aarch64-darwin";
# Keep the workspace locked through uv2nix, but supply the local voice stack
# from nixpkgs so wheel-only transitive artifacts do not break evaluation.
mkPrebuiltPassthru = dependencies: {
inherit dependencies;
optional-dependencies = { };
dependency-groups = { };
};
mkPrebuiltOverride =
final: from: dependencies:
hacks.nixpkgsPrebuilt {
inherit from;
prev = {
nativeBuildInputs = [ final.pyprojectHook ];
passthru = mkPrebuiltPassthru dependencies;
};
};
# Legacy alibabacloud packages ship only sdists with setup.py/setup.cfg
# and no pyproject.toml, so setuptools isn't declared as a build dep.
buildSystemOverrides =
final: prev:
builtins.mapAttrs
(
name: _:
prev.${name}.overrideAttrs (old: {
nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [ final.setuptools ];
})
)
(
lib.genAttrs [
"alibabacloud-credentials-api"
"alibabacloud-endpoint-util"
"alibabacloud-gateway-dingtalk"
"alibabacloud-gateway-spi"
"alibabacloud-tea"
] (_: null)
);
pythonPackageOverrides =
final: _prev:
if isAarch64Darwin then
{
numpy = mkPrebuiltOverride final python312.pkgs.numpy { };
pyarrow = mkPrebuiltOverride final python312.pkgs.pyarrow { };
av = mkPrebuiltOverride final python312.pkgs.av { };
humanfriendly = mkPrebuiltOverride final python312.pkgs.humanfriendly { };
coloredlogs = mkPrebuiltOverride final python312.pkgs.coloredlogs {
humanfriendly = [ ];
};
onnxruntime = mkPrebuiltOverride final python312.pkgs.onnxruntime {
coloredlogs = [ ];
numpy = [ ];
packaging = [ ];
};
ctranslate2 = mkPrebuiltOverride final python312.pkgs.ctranslate2 {
numpy = [ ];
pyyaml = [ ];
};
faster-whisper = mkPrebuiltOverride final python312.pkgs.faster-whisper {
av = [ ];
ctranslate2 = [ ];
huggingface-hub = [ ];
onnxruntime = [ ];
tokenizers = [ ];
tqdm = [ ];
};
}
else
{ };
pythonSet =
(callPackage pyproject-nix.build.packages {
python = python312;
}).overrideScope
(
lib.composeManyExtensions [
pyproject-build-systems.overlays.default
overlay
buildSystemOverrides
pythonPackageOverrides
# ``setup.py`` permits wheel/sdist creation only from the sealed
# Hermes derivation. This is deliberately a derivation environment
# variable, not a devShell variable: ``nix develop -c uv build``
# must remain blocked.
(final: prev: {
hermes-agent = prev.hermes-agent.overrideAttrs (_old: {
HERMES_NIX_BUILD = "1";
});
})
]
);
# The editable venv points at the live checkout, so it uses an
# UNFILTERED workspace rooted at a real path — mkEditablePyprojectOverlay
# computes relative paths via lib.path.splitRoot, which rejects the
# filtered pythonSrc (a cleanSourceWith set, not a path). Filtering
# buys nothing here anyway: the editable install reads from
# $HERMES_PYTHON_SRC_ROOT at runtime.
workspaceRoot = ./..;
editableWorkspace = uv2nix.lib.workspace.loadWorkspace { inherit workspaceRoot; };
editableOverlay = editableWorkspace.mkEditablePyprojectOverlay {
root = "$HERMES_PYTHON_SRC_ROOT"; # resolved at shellHook time
};
editableSet = pythonSet.overrideScope (
lib.composeManyExtensions [
editableOverlay
(final: prev: {
hermes-agent = prev.hermes-agent.overrideAttrs (old: {
# point straight at the real source instead of the filtered nix store copy
src = workspaceRoot;
nativeBuildInputs = old.nativeBuildInputs ++ final.resolveBuildSystem { editables = [ ]; };
});
})
]
);
in
{
venv = pythonSet.mkVirtualEnv "hermes-agent-env" {
hermes-agent = dependency-groups;
};
editableVenv = editableSet.mkVirtualEnv "hermes-agent-editable-env" {
hermes-agent = dependency-groups;
};
}
+124
View File
@@ -0,0 +1,124 @@
{
# electron deps
alsa-lib,
at-spi2-atk,
atk,
cairo,
cups,
dbus,
expat,
fontconfig,
freetype,
glib,
gtk3,
libdrm,
libgbm,
libxkbcommon,
mesa,
nspr,
nss,
pango,
systemd,
libX11,
libXcomposite,
libXdamage,
libXext,
libXfixes,
libXrandr,
libXrender,
libXtst,
libxcb,
# sandbox deps
bash,
bubblewrap,
cacert,
coreutils,
curl,
gawk,
git,
glibc,
gnumake,
gnugrep,
gnused,
gzip,
nodejs_22,
openssl,
python3,
slirp4netns,
stdenv,
gnutar,
util-linux,
# etc
writeShellApplication,
lib,
}:
let
electronRuntime = [
alsa-lib
at-spi2-atk
atk
cairo
cups
dbus
expat
fontconfig
freetype
glib
gtk3
libdrm
libgbm
libxkbcommon
mesa
nspr
nss
pango
systemd
libX11
libXcomposite
libXdamage
libXext
libXfixes
libXrandr
libXrender
libXtst
libxcb
];
in
writeShellApplication {
name = "sandbox";
runtimeInputs = [
bash
bubblewrap
cacert
coreutils
curl
gawk
git
glibc.bin
gnumake
gnugrep
gnused
gzip
nodejs_22
openssl
python3
slirp4netns
stdenv.cc
gnutar
util-linux
]
++ electronRuntime;
text = ''
export DEV_SANDBOX_REAL_CA_CERT=${cacert}/etc/ssl/certs/ca-bundle.crt
export DEV_SANDBOX_DYNAMIC_LINKER=${stdenv.cc.bintools.dynamicLinker}
export DEV_SANDBOX_NODE_DIR=${nodejs_22}
export DEV_SANDBOX_ELECTRON_LD_LIBRARY_PATH=${lib.makeLibraryPath electronRuntime}
# The script is imported into the store as a single file, so its own
# directory has no scripts/sandbox/ beside it. Point it at the assets
# (fake-internet proxy, ssh shim) explicitly.
export DEV_SANDBOX_ASSETS=${../scripts/sandbox}
exec ${../scripts/dev-sandbox.sh} "$@"
'';
}
+29
View File
@@ -0,0 +1,29 @@
# nix/tui.nix — Hermes TUI (Ink/React) compiled with tsc and bundled
{ hermesNpmLib, ... }:
hermesNpmLib.buildNpmPackage {
dirs = [
"ui-tui"
"apps/shared"
];
doCheck = false;
buildPhase = ''
# esbuild bundles everything no need for tsc or vite.
# Run from the workspace root where node_modules/ lives.
node ui-tui/scripts/build.mjs
'';
installPhase = ''
runHook preInstall
mkdir -p $out/lib/hermes-tui
# esbuild writes to ui-tui/dist/ from the source root (no cd).
cp -r ui-tui/dist $out/lib/hermes-tui/dist
# package.json kept for "type": "module" resolution on `node dist/entry.js`.
cp ui-tui/package.json $out/lib/hermes-tui/
runHook postInstall
'';
}
+33
View File
@@ -0,0 +1,33 @@
# nix/web.nix — Hermes Web Dashboard (Vite/React) frontend build
{ hermesNpmLib, ... }:
hermesNpmLib.buildNpmPackage {
dirs = [
"web"
# @hermes/shared ships as a file: workspace dep of web, so its source
# must be in the filtered src tree too.
"apps/shared"
];
doCheck = false;
buildPhase = ''
# Build from web/ so vite.config.ts and tsconfig resolve correctly.
# The workspace root's node_modules/ is at ../node_modules/.
cd web
node ../node_modules/typescript/bin/tsc -b
# outDir in vite.config.ts points to ../hermes_cli/web_dist for the
# monorepo layout. Override with --outDir dist for the nix build.
node ../node_modules/vite/bin/vite.js build --outDir dist
# Return to source root so installPhase paths are correct.
cd ..
'';
installPhase = ''
runHook preInstall
# vite writes to web/dist/ (we cd'd there, overrode outDir, then cd'd back).
cp -r web/dist $out
runHook postInstall
'';
}