mirror of
https://github.com/0xrsydn/nix-hermes-agent.git
synced 2026-08-07 00:53:52 +00:00
`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>
694 lines
24 KiB
Nix
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;
|
|
};
|
|
};
|
|
}
|