nix-hermes-agent/module.nix
0xrsydn f64b73730e fix(module): use string interpolation for custom skill source paths
`toString` converts a Nix path to a string without registering it as a
build input, causing "custom skill source not found" errors in the Nix
sandbox. String interpolation properly copies the path to the store.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 02:31:48 +07:00

694 lines
24 KiB
Nix

self:
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.hermes-agent;
inherit (self.packages.${pkgs.system}) hermes-agent;
hermesUpstreamSrc = cfg.package.upstreamSrc or hermes-agent.upstreamSrc;
# Deep-merge config type (same pattern as nix-openclaw)
deepConfigType = lib.types.mkOptionType {
name = "hermes-config-attrs";
description = "Hermes YAML config (attrset), merged deeply via lib.recursiveUpdate.";
check = builtins.isAttrs;
merge = _loc: defs: lib.foldl' lib.recursiveUpdate { } (map (d: d.value) defs);
};
customSkillType = lib.types.submodule {
options = {
category = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = "Category directory under skills/. If null, installs as a top-level skill.";
};
source = lib.mkOption {
type = lib.types.path;
description = "Path to a local skill directory containing SKILL.md and any linked files.";
};
};
};
customSkillSpecs = lib.mapAttrsToList (name: value: {
inherit name;
inherit (value) category;
source = "${value.source}";
}) cfg.skills.custom;
skillsManaged = cfg.skills.bundled.enable || cfg.skills.optional != [ ] || cfg.skills.custom != { };
managedSkillsTree =
pkgs.runCommand "hermes-managed-skills"
{
bundledEnabled = if cfg.skills.bundled.enable then "1" else "0";
bundledIncludeJson = builtins.toJSON cfg.skills.bundled.include;
optionalSkillsJson = builtins.toJSON cfg.skills.optional;
customSkillsJson = builtins.toJSON customSkillSpecs;
src = hermesUpstreamSrc;
}
''
set -euo pipefail
mkdir -p "$out"
${pkgs.python3}/bin/python3 - <<'PY'
import json
import os
import shutil
from pathlib import Path
out = Path(os.environ["out"])
src = Path(os.environ["src"])
bundled_enabled = os.environ["bundledEnabled"] == "1"
bundled_include = json.loads(os.environ["bundledIncludeJson"])
optional_skills = json.loads(os.environ["optionalSkillsJson"])
custom_skills = json.loads(os.environ["customSkillsJson"])
managed = []
def copy_tree(src_dir: Path, dst_dir: Path):
if not src_dir.exists():
raise SystemExit(f"missing source path: {src_dir}")
if not src_dir.is_dir():
raise SystemExit(f"expected directory, got: {src_dir}")
if dst_dir.exists():
shutil.rmtree(dst_dir)
dst_dir.parent.mkdir(parents=True, exist_ok=True)
shutil.copytree(src_dir, dst_dir)
def skill_dirs_under(root: Path):
if not root.exists():
return []
dirs = []
for skill_md in root.rglob("SKILL.md"):
rel = skill_md.parent.relative_to(root)
if rel.parts and rel.parts[0].startswith('.'):
continue
dirs.append(rel)
return sorted(set(dirs), key=lambda p: tuple(p.parts))
if bundled_enabled:
bundled_root = src / "skills"
if not bundled_root.exists():
raise SystemExit(f"bundled skills directory missing: {bundled_root}")
include_set = set(bundled_include) if bundled_include else None
# Validate include paths exist
if include_set:
all_skills = {str(s) for s in skill_dirs_under(bundled_root)}
missing = include_set - all_skills
if missing:
raise SystemExit(
f"bundled.include references non-existent skills: {', '.join(sorted(missing))}\n"
f"Available: {', '.join(sorted(all_skills))}"
)
for skill_rel in skill_dirs_under(bundled_root):
# If include list is set, only install listed skills
if include_set and str(skill_rel) not in include_set:
continue
copy_tree(bundled_root / skill_rel, out / skill_rel)
managed.append(str(skill_rel))
# Copy DESCRIPTION.md for categories that have at least one included skill
included_categories = {Path(s).parts[0] for s in managed} if managed else set()
for desc in bundled_root.rglob("DESCRIPTION.md"):
rel = desc.relative_to(bundled_root)
cat = rel.parts[0] if rel.parts else None
if include_set and cat and cat not in included_categories:
continue
target = out / rel
target.parent.mkdir(parents=True, exist_ok=True)
if target.exists():
target.chmod(0o644)
shutil.copy2(desc, target)
for rel_str in optional_skills:
rel = Path(rel_str)
if rel.is_absolute() or ".." in rel.parts or len(rel.parts) == 0:
raise SystemExit(f"invalid optional skill path: {rel_str}")
src_dir = src / "optional-skills" / rel
if not src_dir.exists():
raise SystemExit(f"optional skill path not found: {rel_str}")
if not (src_dir / "SKILL.md").exists():
raise SystemExit(f"optional skill missing SKILL.md: {rel_str}")
copy_tree(src_dir, out / rel)
managed.append(str(rel))
if len(rel.parts) > 1:
desc_src = src / "optional-skills" / rel.parts[0] / "DESCRIPTION.md"
if desc_src.exists():
desc_dst = out / rel.parts[0] / "DESCRIPTION.md"
desc_dst.parent.mkdir(parents=True, exist_ok=True)
if desc_dst.exists():
desc_dst.chmod(0o644)
shutil.copy2(desc_src, desc_dst)
for spec in custom_skills:
name = spec["name"]
category = spec.get("category")
src_dir = Path(spec["source"])
rel = Path(category) / name if category else Path(name)
if not src_dir.exists():
raise SystemExit(f"custom skill source not found: {src_dir}")
if not (src_dir / "SKILL.md").exists():
raise SystemExit(f"custom skill missing SKILL.md: {src_dir}")
copy_tree(src_dir, out / rel)
managed.append(str(rel))
(out / ".nix-managed-skills.json").write_text(
json.dumps(sorted(set(managed)), indent=2) + "\n",
encoding="utf-8",
)
PY
'';
# Convert Nix attrset → YAML via JSON intermediary
# (Hermes reads YAML but YAML is a superset of JSON, so JSON works)
configJson = builtins.toJSON cfg.config;
generatedConfigFile = pkgs.writeText "cli-config.yaml" configJson;
configFile = if cfg.configFile != null then cfg.configFile else generatedConfigFile;
# Generate .env file from environment attrset (non-secret env vars)
envFileContent = lib.concatStringsSep "\n" (lib.mapAttrsToList (k: v: "${k}=${v}") cfg.environment);
generatedEnvFile = pkgs.writeText "hermes-env" envFileContent;
# Document files → symlinked into workspace
documentDerivation = pkgs.runCommand "hermes-documents" { } (
''
mkdir -p $out
''
+ lib.concatStringsSep "\n" (
lib.mapAttrsToList (
name: value:
if builtins.isPath value || lib.isStorePath value then
"cp ${value} $out/${name}"
else
"cat > $out/${name} <<'HERMES_DOC_EOF'\n${value}\nHERMES_DOC_EOF"
) cfg.documents
)
);
in
{
options.services.hermes-agent = with lib; {
enable = mkEnableOption "Hermes Agent gateway service";
# ── Package ──────────────────────────────────────────────────────────
package = mkOption {
type = types.package;
default = hermes-agent;
description = "The hermes-agent package to use.";
};
# ── Service identity ─────────────────────────────────────────────────
unitName = mkOption {
type = types.str;
default = "hermes-agent";
description = "systemd unit name (<unitName>.service).";
};
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 (HERMES_HOME base). Contains .hermes/ subdir.";
};
workingDirectory = mkOption {
type = types.str;
default = "${cfg.stateDir}/workspace";
defaultText = literalExpression ''"''${cfg.stateDir}/workspace"'';
description = "Working directory for the agent (MESSAGING_CWD).";
};
# ── Config (declarative YAML) ────────────────────────────────────────
configFile = mkOption {
type = types.nullOr types.path;
default = null;
description = ''
Path to an existing cli-config.yaml. If set, takes precedence over
the declarative `config` option.
'';
};
config = mkOption {
type = deepConfigType;
default = { };
description = ''
Declarative Hermes config (attrset). Deep-merged across module definitions
and rendered as cli-config.yaml. See upstream cli-config.yaml.example for
all available keys.
'';
example = literalExpression ''
{
model = {
default = "anthropic/claude-sonnet-4-20250514";
provider = "openrouter";
};
terminal = {
backend = "local";
timeout = 180;
};
agent = {
max_turns = 60;
reasoning_effort = "medium";
};
memory = {
memory_enabled = true;
user_profile_enabled = true;
};
toolsets = [ "all" ];
}
'';
};
# ── Skills (declarative materialization) ─────────────────────────────
skills = mkOption {
default = { };
description = ''
Declarative Hermes skills materialized into `${cfg.stateDir}/.hermes/skills`.
Phase 1 supports bundled upstream skills, selected optional skills,
and custom local skills.
'';
type = types.submodule {
options = {
bundled.enable = mkOption {
type = types.bool;
default = true;
description = ''
Install upstream bundled skills declaratively into HERMES_HOME/skills.
'';
};
bundled.include = mkOption {
type = types.listOf types.str;
default = [ ];
example = [
"autonomous-ai-agents/claude-code"
"software-development/code-review"
"research/arxiv"
];
description = ''
When non-empty, only install these specific bundled skills (by relative
path under upstream `skills/`). When empty (default), all bundled skills
are installed. Has no effect when `bundled.enable` is false.
Invalid paths cause a build error listing available skills.
'';
};
optional = mkOption {
type = types.listOf types.str;
default = [ ];
example = [
"creative/storytelling"
"research/deep-research"
];
description = ''
Relative paths under upstream `optional-skills/` to install.
Example: `creative/some-skill`.
'';
};
custom = mkOption {
type = types.attrsOf customSkillType;
default = { };
description = ''
Custom local skills keyed by installed skill name. Each entry points
to a directory containing `SKILL.md` and any linked files.
'';
example = literalExpression ''
{
repo-watch = {
category = "research";
source = ./skills/repo-watch;
};
}
'';
};
};
};
};
# ── Secrets / environment ────────────────────────────────────────────
environmentFiles = mkOption {
type = types.listOf types.str;
default = [ ];
description = ''
Paths to environment files containing secrets (API keys, tokens).
These are passed as systemd EnvironmentFile= entries.
Use leading '-' to ignore missing files.
Example file contents:
ANTHROPIC_API_KEY=sk-ant-...
OPENROUTER_API_KEY=sk-or-...
TELEGRAM_TOKEN=123456:ABC...
'';
};
environment = mkOption {
type = types.attrsOf types.str;
default = { };
description = ''
Non-secret environment variables for Hermes. These are written to
an env file (visible in the Nix store — do NOT put secrets here).
Use `environmentFiles` for secrets.
'';
example = literalExpression ''
{
LLM_MODEL = "anthropic/claude-opus-4.6";
MESSAGING_CWD = "/var/lib/hermes/workspace";
}
'';
};
authFile = mkOption {
type = types.nullOr types.path;
default = null;
description = ''
Path to an auth.json seed file containing OAuth credentials
(Nous Portal, Codex, Anthropic OAuth).
This file is only copied on first deploy — if auth.json already exists
in the state directory, it is NOT overwritten. This preserves runtime
token refreshes across rebuilds and restarts.
To force a re-seed, delete the existing auth.json first:
rm /var/lib/hermes/.hermes/auth.json
If null, auth.json is managed entirely at runtime via `hermes login`.
'';
};
authFileForceOverwrite = mkOption {
type = types.bool;
default = false;
description = ''
If true, always overwrite auth.json from authFile on activation.
WARNING: This destroys any runtime-refreshed tokens.
Only use for testing or when you know the seed file has fresh tokens.
'';
};
# ── Documents (SOUL.md, AGENTS.md, etc.) ─────────────────────────────
documents = mkOption {
type = types.attrsOf (types.either types.str types.path);
default = { };
description = ''
Workspace document files. Keys are filenames, values are either
inline strings or paths to files. These are symlinked into the
workspace directory on activation.
'';
example = literalExpression ''
{
"SOUL.md" = '''
# SOUL.md
You are a helpful AI assistant.
''';
"AGENTS.md" = ./my-agents.md;
"USER.md" = '''
# USER.md
Name: Fay
''';
}
'';
};
# ── Service behavior ─────────────────────────────────────────────────
logPath = mkOption {
type = types.str;
default = "${cfg.stateDir}/logs/gateway.log";
defaultText = literalExpression ''"''${cfg.stateDir}/logs/gateway.log"'';
description = "Log file path.";
};
extraArgs = mkOption {
type = types.listOf types.str;
default = [ ];
description = "Extra command-line arguments for `hermes gateway`.";
};
execStart = mkOption {
type = types.nullOr types.str;
default = null;
description = "Override ExecStart command. If unset, runs: hermes gateway.";
};
extraPackages = mkOption {
type = types.listOf types.package;
default = [ ];
description = "Extra packages to make available on PATH.";
};
restart = mkOption {
type = types.str;
default = "always";
description = "systemd Restart= policy.";
};
restartSec = mkOption {
type = types.int;
default = 5;
description = "systemd RestartSec= value.";
};
# ── MCP servers ──────────────────────────────────────────────────────
mcpServers = mkOption {
type = types.attrsOf (
types.submodule {
options = {
command = mkOption {
type = types.str;
description = "MCP server command.";
};
args = mkOption {
type = types.listOf types.str;
default = [ ];
};
env = mkOption {
type = types.attrsOf types.str;
default = { };
};
timeout = mkOption {
type = types.nullOr types.int;
default = null;
};
};
}
);
default = { };
description = "MCP server configurations (merged into config.mcp_servers).";
};
};
config = lib.mkIf cfg.enable {
# ── Merge MCP servers into config ────────────────────────────────────
services.hermes-agent.config = lib.mkIf (cfg.mcpServers != { }) {
mcp_servers = lib.mapAttrs (
_name: srv:
{
inherit (srv) command args;
}
// lib.optionalAttrs (srv.env != { }) { inherit (srv) env; }
// lib.optionalAttrs (srv.timeout != null) { inherit (srv) timeout; }
) cfg.mcpServers;
};
# ── User / group ─────────────────────────────────────────────────────
users.groups.${cfg.group} = lib.mkIf cfg.createUser { };
users.users.${cfg.user} = lib.mkIf cfg.createUser {
isSystemUser = true;
inherit (cfg) group;
home = cfg.stateDir;
createHome = true;
shell = pkgs.bashInteractive;
};
# ── Directories ──────────────────────────────────────────────────────
systemd.tmpfiles.rules = [
"d ${cfg.stateDir} 0750 ${cfg.user} ${cfg.group} - -"
"d ${cfg.stateDir}/.hermes 0750 ${cfg.user} ${cfg.group} - -"
"d ${cfg.stateDir}/.hermes/skills 0750 ${cfg.user} ${cfg.group} - -"
"d ${cfg.workingDirectory} 0750 ${cfg.user} ${cfg.group} - -"
"d ${builtins.dirOf cfg.logPath} 0750 ${cfg.user} ${cfg.group} - -"
];
# ── Activation: link config + documents + managed skills into state dir ──
system.activationScripts."hermes-agent-setup" = lib.stringAfter [ "users" ] ''
set -euo pipefail
# Ensure required directories exist during activation (do not rely on tmpfiles ordering)
install -d -o ${cfg.user} -g ${cfg.group} -m 0750 ${cfg.stateDir}
install -d -o ${cfg.user} -g ${cfg.group} -m 0750 ${cfg.stateDir}/.hermes
install -d -o ${cfg.user} -g ${cfg.group} -m 0750 ${cfg.stateDir}/.hermes/skills
install -d -o ${cfg.user} -g ${cfg.group} -m 0750 ${cfg.workingDirectory}
install -d -o ${cfg.user} -g ${cfg.group} -m 0750 ${builtins.dirOf cfg.logPath}
# Link config file
install -o ${cfg.user} -g ${cfg.group} -m 0640 -D ${configFile} ${cfg.stateDir}/.hermes/cli-config.yaml
# Seed auth file if provided (only if not already present, unless force overwrite)
${lib.optionalString (cfg.authFile != null) ''
${
if cfg.authFileForceOverwrite then
''
install -o ${cfg.user} -g ${cfg.group} -m 0600 ${cfg.authFile} ${cfg.stateDir}/.hermes/auth.json
''
else
''
if [ ! -f ${cfg.stateDir}/.hermes/auth.json ]; then
install -o ${cfg.user} -g ${cfg.group} -m 0600 ${cfg.authFile} ${cfg.stateDir}/.hermes/auth.json
fi
''
}
''}
# Link documents into workspace
${lib.concatStringsSep "\n" (
lib.mapAttrsToList (name: _value: ''
install -o ${cfg.user} -g ${cfg.group} -m 0644 ${documentDerivation}/${name} ${cfg.workingDirectory}/${name}
'') cfg.documents
)}
# Reconcile Nix-managed skills without touching hub/runtime-managed state
${lib.optionalString skillsManaged ''
skills_dir=${cfg.stateDir}/.hermes/skills
managed_tree=${managedSkillsTree}
state_file="$skills_dir/.nix-managed-skills.json"
desired_file="$managed_tree/.nix-managed-skills.json"
install -d -o ${cfg.user} -g ${cfg.group} -m 0750 "$skills_dir"
skills_dir="$skills_dir" \
managed_tree="$managed_tree" \
state_file="$state_file" \
desired_file="$desired_file" \
${pkgs.python3}/bin/python3 - <<'PY'
import json
import os
import shutil
from pathlib import Path
skills_dir = Path(os.environ["skills_dir"])
managed_tree = Path(os.environ["managed_tree"])
state_file = Path(os.environ["state_file"])
desired_file = Path(os.environ["desired_file"])
old = []
if state_file.exists():
old = json.loads(state_file.read_text(encoding="utf-8"))
new = json.loads(desired_file.read_text(encoding="utf-8"))
for rel in sorted(set(old) - set(new), reverse=True):
target = skills_dir / rel
if target.exists():
shutil.rmtree(target)
for rel in new:
src = managed_tree / rel
dst = skills_dir / rel
if dst.exists():
shutil.rmtree(dst)
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copytree(src, dst)
for desc in managed_tree.rglob("DESCRIPTION.md"):
rel = desc.relative_to(managed_tree)
dst = skills_dir / rel
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(desc, dst)
state_file.write_text(json.dumps(new, indent=2) + "\n", encoding="utf-8")
PY
chown -R ${cfg.user}:${cfg.group} "$skills_dir"
''}
'';
# ── systemd service ──────────────────────────────────────────────────
systemd.services.${cfg.unitName} = {
description = "Hermes Agent Gateway";
wantedBy = [ "multi-user.target" ];
after = [ "network-online.target" ];
wants = [ "network-online.target" ];
environment = {
HOME = cfg.stateDir;
HERMES_HOME = "${cfg.stateDir}/.hermes";
MESSAGING_CWD = cfg.workingDirectory;
};
serviceConfig = {
User = cfg.user;
Group = cfg.group;
WorkingDirectory = cfg.workingDirectory;
EnvironmentFile = [ generatedEnvFile ] ++ cfg.environmentFiles;
ExecStart =
if cfg.execStart != null then
cfg.execStart
else
lib.concatStringsSep " " (
[
"${cfg.package}/bin/hermes"
"gateway"
]
++ cfg.extraArgs
);
Restart = cfg.restart;
RestartSec = cfg.restartSec;
StandardOutput = "append:${cfg.logPath}";
StandardError = "append:${cfg.logPath}";
# Hardening
NoNewPrivileges = true;
ProtectSystem = "strict";
ProtectHome = false;
ReadWritePaths = [ cfg.stateDir ];
PrivateTmp = true;
};
path = [
cfg.package
pkgs.bash
pkgs.coreutils
pkgs.git
]
++ cfg.extraPackages;
};
};
}