mirror of
https://github.com/0xrsydn/nix-hermes-agent.git
synced 2026-08-07 00:53:52 +00:00
When bundled.include is set to a non-empty list, only the listed skills are installed from the upstream bundled skills directory. When empty (default), all bundled skills are installed (existing behavior). Invalid include paths cause a build error with available skills listed. DESCRIPTION.md files are only copied for categories that have at least one included skill.
690 lines
24 KiB
Nix
690 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 = toString 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)
|
|
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)
|
|
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;
|
|
};
|
|
};
|
|
}
|