mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-10-20 05:15:31 +00:00
feat(nix): NixOS module with persistent container mode, managed guards, checks
- Replace homeModules.nix with nixosModules.nix (two deployment modes) - Mode A (native): hardened systemd service with ProtectSystem=strict - Mode B (container): persistent Ubuntu container with /nix/store bind-mount, identity-hash-based recreation, GC root protection, symlink-based updates - Add HERMES_MANAGED guards blocking CLI config mutation (config set, setup, gateway install/uninstall) when running under NixOS module - Add nix/checks.nix with build-time verification (binary, CLI, managed guard) - Remove container.nix (no Nix-built OCI image; pulls ubuntu:24.04 at runtime) - Simplify packages.nix (drop fetchFromGitHub submodules, PYTHONPATH wrappers) - Rewrite docs/nixos-setup.md with full options reference, container architecture, secrets management, and troubleshooting guide Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
1e8fae283f
commit
b51a5b201e
9 changed files with 1019 additions and 217 deletions
|
|
@ -2,8 +2,8 @@
|
|||
|
||||
## Prerequisites
|
||||
|
||||
- Nix with flakes enabled (we recommend [Determinate Nix](https://install.determinate.systems) which enables flakes by default)
|
||||
- API keys for the services you want to use (at minimum: OpenRouter)
|
||||
- Nix with flakes enabled ([Determinate Nix](https://install.determinate.systems) recommended — enables flakes by default)
|
||||
- API keys for the services you want to use (at minimum: an OpenRouter or Anthropic key)
|
||||
|
||||
## Quick Start: `nix run`
|
||||
|
||||
|
|
@ -12,180 +12,523 @@ nix run github:NousResearch/hermes-agent -- setup
|
|||
nix run github:NousResearch/hermes-agent -- chat
|
||||
```
|
||||
|
||||
No clone needed. Nix fetches and builds everything.
|
||||
No clone needed. Nix fetches and builds everything. All Python dependencies are
|
||||
Nix derivations via uv2nix — no runtime pip.
|
||||
|
||||
## Install
|
||||
|
||||
All Python dependencies are pre-built as Nix derivations via uv2nix — no runtime pip install.
|
||||
## Install to Profile
|
||||
|
||||
```bash
|
||||
# From remote
|
||||
nix profile install github:NousResearch/hermes-agent
|
||||
hermes setup
|
||||
|
||||
# From a clone
|
||||
git clone --recurse-submodules https://github.com/NousResearch/hermes-agent.git
|
||||
git clone https://github.com/NousResearch/hermes-agent.git
|
||||
cd hermes-agent
|
||||
nix build
|
||||
./result/bin/hermes setup
|
||||
./result/bin/hermes chat
|
||||
|
||||
# Or install into your profile
|
||||
nix profile install github:NousResearch/hermes-agent
|
||||
hermes setup
|
||||
```
|
||||
|
||||
## Development (`nix develop`)
|
||||
|
||||
For hacking on hermes-agent locally:
|
||||
## Development Shell
|
||||
|
||||
```bash
|
||||
cd hermes-agent
|
||||
nix develop
|
||||
# Shell automatically:
|
||||
# - Creates .venv with Python 3.11
|
||||
# - Installs all Python deps via uv
|
||||
# - Installs npm deps (agent-browser)
|
||||
# - Puts ripgrep, git, node on PATH
|
||||
# Shell provides:
|
||||
# - Python 3.11 venv with all deps (via uv)
|
||||
# - npm deps (agent-browser)
|
||||
# - ripgrep, git, node on PATH
|
||||
|
||||
hermes setup
|
||||
hermes
|
||||
hermes chat
|
||||
```
|
||||
|
||||
### Using direnv (recommended)
|
||||
### direnv (recommended)
|
||||
|
||||
If you have [direnv](https://direnv.net/) installed, the included `.envrc` will
|
||||
automatically activate the dev shell when you `cd` into the repo:
|
||||
The included `.envrc` activates the dev shell automatically:
|
||||
|
||||
```bash
|
||||
cd hermes-agent
|
||||
direnv allow # one-time approval
|
||||
|
||||
# From now on, entering the directory activates the environment automatically.
|
||||
# On repeat entry, the stamp file check skips dependency installation (~instant).
|
||||
direnv allow # one-time
|
||||
# Subsequent entries are near-instant (stamp file skips dep install)
|
||||
```
|
||||
|
||||
## Persistent Messaging Gateway
|
||||
---
|
||||
|
||||
To run hermes-agent as a **always-on service** for Telegram, Discord, or Slack (with built-in cron scheduler), use the [home-manager](https://github.com/nix-community/home-manager) module. This is how messaging platforms are meant to be run — the gateway stays up, receives messages, and responds. Works on any Linux distribution with Nix, not just NixOS.
|
||||
## NixOS Module
|
||||
|
||||
> This assumes you already have home-manager set up. If you don't, see the [home-manager docs](https://nix-community.github.io/home-manager/) first.
|
||||
The flake exports `nixosModules.default` with two deployment modes:
|
||||
|
||||
### Step 1: Add the flake input
|
||||
| Mode | `container.enable` | How it runs | Use case |
|
||||
|---|---|---|---|
|
||||
| **Native** (default) | `false` | Hardened systemd service, runs directly on host | Standard deployment, maximum security |
|
||||
| **Container** | `true` | Persistent Ubuntu container, hermes binary bind-mounted from `/nix/store` | Agent needs `apt`/`pip`/`npm` self-install capability |
|
||||
|
||||
Both modes share the same option surface. The module manages user creation,
|
||||
directory setup, config generation, secrets, documents, and service lifecycle.
|
||||
|
||||
> **Note:** This module requires NixOS. For non-NixOS systems, use
|
||||
> `nix profile install` + the CLI's built-in `hermes gateway install`.
|
||||
|
||||
### Add the Flake Input
|
||||
|
||||
```nix
|
||||
# ~/.config/home-manager/flake.nix (or wherever your HM flake lives)
|
||||
# /etc/nixos/flake.nix (or your system flake)
|
||||
{
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
home-manager = {
|
||||
url = "github:nix-community/home-manager";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11";
|
||||
hermes-agent.url = "github:NousResearch/hermes-agent";
|
||||
};
|
||||
|
||||
outputs = { nixpkgs, home-manager, hermes-agent, ... }: {
|
||||
homeConfigurations."your-username" = home-manager.lib.homeManagerConfiguration {
|
||||
pkgs = nixpkgs.legacyPackages.x86_64-linux;
|
||||
outputs = { nixpkgs, hermes-agent, ... }: {
|
||||
nixosConfigurations.your-host = nixpkgs.lib.nixosSystem {
|
||||
system = "x86_64-linux";
|
||||
modules = [
|
||||
hermes-agent.homeManagerModules.default
|
||||
./home.nix
|
||||
hermes-agent.nixosModules.default
|
||||
./configuration.nix
|
||||
];
|
||||
};
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2: Enable in home.nix
|
||||
### Minimal Configuration (Native Mode)
|
||||
|
||||
```nix
|
||||
# home.nix
|
||||
# configuration.nix
|
||||
{
|
||||
services.hermes-agent = {
|
||||
enable = true;
|
||||
gateway.enable = true;
|
||||
|
||||
# All options with defaults:
|
||||
# hermesHome = "~/.hermes"; # config, sessions, memories
|
||||
# environmentFile = "~/.hermes/.env"; # API keys
|
||||
# messagingCwd = "~"; # gateway working directory
|
||||
# addToPATH = true; # adds `hermes` CLI to PATH
|
||||
settings.model.default = "anthropic/claude-sonnet-4";
|
||||
environmentFiles = [ config.sops.secrets."hermes/env".path ];
|
||||
addToSystemPackages = true; # puts `hermes` CLI on PATH
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: API keys
|
||||
### Minimal Configuration (Container Mode)
|
||||
|
||||
```bash
|
||||
hermes setup
|
||||
```nix
|
||||
{
|
||||
services.hermes-agent = {
|
||||
enable = true;
|
||||
container.enable = true;
|
||||
settings.model.default = "anthropic/claude-sonnet-4";
|
||||
environmentFiles = [ config.sops.secrets."hermes/env".path ];
|
||||
addToSystemPackages = true;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
The interactive wizard walks you through configuring API keys (OpenRouter, Telegram, Discord, etc.) and writes them to `~/.hermes/.env`.
|
||||
> Container mode auto-enables `virtualisation.docker.enable` via `mkDefault`.
|
||||
> Override with `virtualisation.docker.enable = false;` if using podman
|
||||
> (`container.backend = "podman"`).
|
||||
|
||||
### Step 4: Enable linger + activate
|
||||
### Full Example
|
||||
|
||||
```bash
|
||||
# Lets user services survive logout
|
||||
sudo loginctl enable-linger $USER
|
||||
```nix
|
||||
{ config, ... }: {
|
||||
services.hermes-agent = {
|
||||
enable = true;
|
||||
container.enable = true;
|
||||
|
||||
# Activate — all deps are pre-built, this is fast
|
||||
home-manager switch
|
||||
# ── Model ──────────────────────────────────────────────────────────
|
||||
settings = {
|
||||
model = {
|
||||
base_url = "https://openrouter.ai/api/v1";
|
||||
default = "anthropic/claude-opus-4.6";
|
||||
};
|
||||
toolsets = [ "all" ];
|
||||
max_turns = 100;
|
||||
terminal = { backend = "local"; cwd = "."; timeout = 180; };
|
||||
compression = {
|
||||
enabled = true;
|
||||
threshold = 0.85;
|
||||
summary_model = "google/gemini-3-flash-preview";
|
||||
};
|
||||
memory = { memory_enabled = true; user_profile_enabled = true; };
|
||||
display = { compact = false; personality = "kawaii"; };
|
||||
agent = { max_turns = 60; verbose = false; };
|
||||
};
|
||||
|
||||
# ── Secrets ────────────────────────────────────────────────────────
|
||||
# See "Secrets Management" section below
|
||||
environmentFiles = [ config.sops.secrets."hermes/env".path ];
|
||||
|
||||
# ── Documents ──────────────────────────────────────────────────────
|
||||
documents = {
|
||||
"SOUL.md" = builtins.readFile /home/user/.hermes/SOUL.md;
|
||||
"USER.md" = ./documents/USER.md; # path reference
|
||||
};
|
||||
|
||||
# ── MCP Servers ────────────────────────────────────────────────────
|
||||
mcpServers.filesystem = {
|
||||
command = "npx";
|
||||
args = [ "-y" "@anthropic/mcp-filesystem" "/data/workspace" ];
|
||||
};
|
||||
|
||||
# ── Container options ──────────────────────────────────────────────
|
||||
container = {
|
||||
image = "ubuntu:24.04"; # default
|
||||
backend = "docker"; # or "podman"
|
||||
extraVolumes = [
|
||||
"/home/user/projects:/projects:rw"
|
||||
];
|
||||
extraOptions = [
|
||||
"--gpus" "all" # GPU passthrough
|
||||
];
|
||||
};
|
||||
|
||||
# ── Service tuning ─────────────────────────────────────────────────
|
||||
addToSystemPackages = true;
|
||||
extraArgs = [ "--verbose" ];
|
||||
restart = "always";
|
||||
restartSec = 5;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Step 5: Verify
|
||||
---
|
||||
|
||||
```bash
|
||||
systemctl --user status hermes-agent-gateway
|
||||
journalctl --user -u hermes-agent-gateway -f
|
||||
hermes doctor
|
||||
## Secrets Management
|
||||
|
||||
**Never put API keys in `environment`** — values end up in the Nix store
|
||||
(world-readable). Use `environmentFiles` with a secrets manager.
|
||||
|
||||
### sops-nix
|
||||
|
||||
```nix
|
||||
{
|
||||
sops = {
|
||||
defaultSopsFile = ./secrets/hermes.yaml;
|
||||
age.keyFile = "/home/user/.config/sops/age/keys.txt";
|
||||
secrets."hermes-env" = { format = "yaml"; };
|
||||
};
|
||||
|
||||
services.hermes-agent.environmentFiles = [
|
||||
config.sops.secrets."hermes-env".path
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
## Directory layout
|
||||
The secrets file should contain key-value pairs:
|
||||
|
||||
```
|
||||
~/.hermes/ # Config & data
|
||||
├── .env # API keys
|
||||
├── config.yaml # Agent configuration
|
||||
├── sessions/ # Messaging sessions
|
||||
├── memories/ # Agent memories
|
||||
├── skills/ # Knowledge documents
|
||||
├── cron/ # Scheduled jobs
|
||||
└── logs/ # Session logs
|
||||
```yaml
|
||||
# secrets/hermes.yaml (encrypted with sops)
|
||||
hermes-env: |
|
||||
OPENROUTER_API_KEY=sk-or-...
|
||||
TELEGRAM_BOT_TOKEN=123456:ABC...
|
||||
ANTHROPIC_API_KEY=sk-ant-...
|
||||
```
|
||||
|
||||
## Customizing
|
||||
### agenix
|
||||
|
||||
```bash
|
||||
$EDITOR ~/.hermes/config.yaml
|
||||
```nix
|
||||
{
|
||||
age.secrets.hermes-env.file = ./secrets/hermes-env.age;
|
||||
|
||||
services.hermes-agent.environmentFiles = [
|
||||
config.age.secrets.hermes-env.path
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
Key settings:
|
||||
- `model.default` — Which LLM to use (default: `anthropic/claude-opus-4.6`)
|
||||
- `terminal.env_type` — Terminal backend: `local`, `docker`, `ssh`
|
||||
- `toolsets` — Which tools to enable (default: all)
|
||||
### OAuth / Auth Seeding
|
||||
|
||||
After editing, restart the service:
|
||||
For platforms requiring OAuth (e.g., Discord), use `authFile` to seed
|
||||
credentials on first deploy:
|
||||
|
||||
```bash
|
||||
systemctl --user restart hermes-agent-gateway
|
||||
```nix
|
||||
{
|
||||
services.hermes-agent = {
|
||||
authFile = config.sops.secrets."hermes/auth.json".path;
|
||||
# authFileForceOverwrite = true; # overwrite on every activation
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
The file is only copied if `auth.json` doesn't already exist (unless
|
||||
`authFileForceOverwrite = true`). Runtime OAuth token refreshes are
|
||||
written back to the state directory and preserved across rebuilds.
|
||||
|
||||
---
|
||||
|
||||
## Container Mode: Architecture
|
||||
|
||||
When `container.enable = true`, hermes runs inside a persistent Ubuntu
|
||||
container with the Nix-built binary bind-mounted from the host:
|
||||
|
||||
```
|
||||
Host Container
|
||||
──── ─────────
|
||||
/nix/store/...-hermes-agent-0.1.0 ──► /nix/store/... (ro)
|
||||
/var/lib/hermes/ ──► /data/ (rw)
|
||||
├── current-package -> /nix/store/... (symlink, updated each rebuild)
|
||||
├── .gc-root -> /nix/store/... (prevents nix-collect-garbage)
|
||||
├── .container-identity (sha256 hash, triggers recreation)
|
||||
├── .hermes/ (HERMES_HOME)
|
||||
│ ├── config.yaml (Nix-generated, copied by activation)
|
||||
│ ├── .managed (marker file)
|
||||
│ ├── state.db
|
||||
│ ├── sessions/
|
||||
│ ├── memories/
|
||||
│ └── ...
|
||||
└── workspace/ (MESSAGING_CWD)
|
||||
├── SOUL.md (from documents option)
|
||||
└── (agent-created files)
|
||||
|
||||
Container writable layer (apt/pip/npm): /usr, /home/hermes, /tmp
|
||||
```
|
||||
|
||||
The container entrypoint is `/data/current-package/bin/hermes gateway run --replace`,
|
||||
which resolves through the symlink to the current Nix store path.
|
||||
|
||||
### What Persists Across What
|
||||
|
||||
| Event | Container recreated? | `/data` (state) | Writable layer (`apt`/`pip`/`npm`) |
|
||||
|---|---|---|---|
|
||||
| `systemctl restart hermes-agent` | No | Persists | Persists |
|
||||
| `nixos-rebuild switch` (code change) | No (symlink updated) | Persists | Persists |
|
||||
| Host reboot | No | Persists | Persists |
|
||||
| `nix-collect-garbage` | No (GC root) | Persists | Persists |
|
||||
| Image change (`container.image`) | **Yes** | Persists | **Lost** |
|
||||
| Env/volume/options change | **Yes** | Persists | **Lost** |
|
||||
|
||||
The container is only recreated when its **identity hash** changes. The hash
|
||||
covers: `image`, `environment`, `environmentFiles`, `extraVolumes`,
|
||||
`extraOptions`. Changes to `settings`, `documents`, or the hermes package
|
||||
itself do **not** trigger recreation — they take effect via the stateDir bind
|
||||
mount and the `current-package` symlink.
|
||||
|
||||
### When to Use Container Mode
|
||||
|
||||
Use container mode when:
|
||||
- The agent needs to `apt install`, `pip install`, or `npm install` packages at runtime
|
||||
- You want the agent to have a mutable Linux environment it can customize
|
||||
- You're running untrusted or experimental tool configurations
|
||||
|
||||
Use native mode when:
|
||||
- You want maximum security (systemd hardening: `NoNewPrivileges`, `ProtectSystem=strict`)
|
||||
- The agent only needs tools already on the Nix-provided PATH
|
||||
- You prefer a minimal, reproducible deployment
|
||||
|
||||
---
|
||||
|
||||
## Managed Mode
|
||||
|
||||
When hermes runs via the NixOS module, the following CLI commands are
|
||||
**blocked** with a descriptive error:
|
||||
|
||||
| Blocked command | Reason |
|
||||
|---|---|
|
||||
| `hermes setup` | Config is declarative in `configuration.nix` |
|
||||
| `hermes config edit` | Config is generated from `settings` |
|
||||
| `hermes config set <key> <value>` | Config is generated from `settings` |
|
||||
| `hermes gateway install` | Service is managed by NixOS |
|
||||
| `hermes gateway uninstall` | Service is managed by NixOS |
|
||||
|
||||
Detection uses two signals:
|
||||
1. `HERMES_MANAGED=true` environment variable (set by the systemd service)
|
||||
2. `.managed` marker file in `HERMES_HOME` (set by the activation script,
|
||||
visible to interactive shells)
|
||||
|
||||
If you need to change configuration, edit your `configuration.nix` and run
|
||||
`sudo nixos-rebuild switch`.
|
||||
|
||||
---
|
||||
|
||||
## Options Reference
|
||||
|
||||
### Core
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `enable` | `bool` | `false` | Enable the hermes-agent service |
|
||||
| `package` | `package` | `hermes-agent` | The hermes-agent package |
|
||||
| `user` | `str` | `"hermes"` | System user |
|
||||
| `group` | `str` | `"hermes"` | System group |
|
||||
| `createUser` | `bool` | `true` | Auto-create user/group |
|
||||
| `stateDir` | `str` | `"/var/lib/hermes"` | State directory (`HERMES_HOME` parent) |
|
||||
| `workingDirectory` | `str` | `"${stateDir}/workspace"` | Agent working directory (`MESSAGING_CWD`) |
|
||||
| `addToSystemPackages` | `bool` | `false` | Add `hermes` CLI to system PATH |
|
||||
|
||||
### Configuration
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `settings` | `attrs` (deep-merged) | `{}` | Declarative config rendered as `config.yaml`. Supports arbitrary nesting; multiple definitions are merged via `lib.recursiveUpdate` |
|
||||
| `configFile` | `null` or `path` | `null` | Path to an existing `config.yaml`. Overrides `settings` entirely if set |
|
||||
|
||||
### Secrets & Environment
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `environmentFiles` | `listOf str` | `[]` | Paths to env files with secrets (API keys). Passed as systemd `EnvironmentFile=` or docker `--env-file` |
|
||||
| `environment` | `attrsOf str` | `{}` | Non-secret env vars. **Visible in Nix store** — do not put secrets here |
|
||||
| `authFile` | `null` or `path` | `null` | OAuth credentials seed. Only copied on first deploy |
|
||||
| `authFileForceOverwrite` | `bool` | `false` | Always overwrite `auth.json` from `authFile` |
|
||||
|
||||
### Documents
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `documents` | `attrsOf (either str path)` | `{}` | Workspace files. Keys are filenames, values are inline strings or paths. Installed into `workingDirectory` on activation |
|
||||
|
||||
### MCP Servers
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `mcpServers` | `attrsOf submodule` | `{}` | MCP server definitions, merged into `settings.mcp_servers` |
|
||||
| `mcpServers.<name>.command` | `str` | — | Server command |
|
||||
| `mcpServers.<name>.args` | `listOf str` | `[]` | Command arguments |
|
||||
| `mcpServers.<name>.env` | `attrsOf str` | `{}` | Server environment |
|
||||
| `mcpServers.<name>.timeout` | `null` or `int` | `null` | Timeout in seconds |
|
||||
|
||||
### Service Behavior
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `extraArgs` | `listOf str` | `[]` | Extra args for `hermes gateway` |
|
||||
| `extraPackages` | `listOf package` | `[]` | Extra packages on service PATH (native mode only) |
|
||||
| `restart` | `str` | `"always"` | systemd `Restart=` policy |
|
||||
| `restartSec` | `int` | `5` | systemd `RestartSec=` |
|
||||
|
||||
### Container
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `container.enable` | `bool` | `false` | Enable OCI container mode |
|
||||
| `container.backend` | `enum ["docker" "podman"]` | `"docker"` | Container runtime. Auto-enables `virtualisation.docker.enable` when `"docker"` |
|
||||
| `container.image` | `str` | `"ubuntu:24.04"` | Base image. Pulled at runtime by Docker/Podman |
|
||||
| `container.extraVolumes` | `listOf str` | `[]` | Extra volume mounts (`host:container:mode`) |
|
||||
| `container.extraOptions` | `listOf str` | `[]` | Extra args passed to `docker create` |
|
||||
|
||||
---
|
||||
|
||||
## Directory Layout
|
||||
|
||||
### Native Mode
|
||||
|
||||
```
|
||||
/var/lib/hermes/ # stateDir (owned by hermes:hermes, 0750)
|
||||
├── .hermes/ # HERMES_HOME
|
||||
│ ├── config.yaml # Nix-generated (overwritten each rebuild)
|
||||
│ ├── .managed # Marker: CLI config mutation blocked
|
||||
│ ├── .env # (not used — secrets via environmentFiles)
|
||||
│ ├── auth.json # OAuth credentials (seeded, then self-managed)
|
||||
│ ├── gateway.pid
|
||||
│ ├── state.db
|
||||
│ ├── sessions/
|
||||
│ ├── memories/
|
||||
│ ├── skills/
|
||||
│ ├── cron/
|
||||
│ └── logs/
|
||||
└── workspace/ # MESSAGING_CWD
|
||||
├── SOUL.md # From documents option
|
||||
└── (agent-created files)
|
||||
```
|
||||
|
||||
### Container Mode
|
||||
|
||||
Same layout, but mounted into the container as `/data`:
|
||||
|
||||
| Container path | Host path | Mode | Notes |
|
||||
|---|---|---|---|
|
||||
| `/nix/store` | `/nix/store` | `ro` | Hermes binary + all Nix deps |
|
||||
| `/data` | `/var/lib/hermes` | `rw` | All state, config, workspace |
|
||||
| `/home/hermes` | (container layer) | `rw` | Persists — agent home, `pip install --user` |
|
||||
| `/usr`, `/usr/local` | (container layer) | `rw` | Persists — `apt`/`pip`/`npm` installs |
|
||||
| `/tmp` | (container layer) | `rw` | Persists across restarts (lost on recreation) |
|
||||
|
||||
---
|
||||
|
||||
## Updating
|
||||
|
||||
```bash
|
||||
nix flake update hermes-agent --flake ~/.config/home-manager
|
||||
home-manager switch
|
||||
# Update the flake input
|
||||
nix flake update hermes-agent --flake /etc/nixos
|
||||
|
||||
# Rebuild — in container mode, the symlink updates without recreating the container
|
||||
sudo nixos-rebuild switch
|
||||
```
|
||||
|
||||
In container mode, the agent picks up the new binary immediately on restart.
|
||||
No container recreation, no loss of `apt`/`pip`/`npm` installs.
|
||||
|
||||
## Flake Checks
|
||||
|
||||
The flake includes build-time verification:
|
||||
|
||||
```bash
|
||||
# Run all checks
|
||||
nix flake check
|
||||
|
||||
# Individual checks
|
||||
nix build .#checks.x86_64-linux.package-contents # binaries exist + version
|
||||
nix build .#checks.x86_64-linux.cli-commands # gateway/config subcommands
|
||||
nix build .#checks.x86_64-linux.managed-guard # HERMES_MANAGED blocks mutation
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Service logs
|
||||
|
||||
```bash
|
||||
# Gateway logs
|
||||
journalctl --user -u hermes-agent-gateway -f
|
||||
# Native mode
|
||||
journalctl -u hermes-agent -f
|
||||
|
||||
# Check CLI + deps
|
||||
hermes doctor
|
||||
# Container mode — same unit name
|
||||
journalctl -u hermes-agent -f
|
||||
|
||||
# Restart gateway
|
||||
systemctl --user restart hermes-agent-gateway
|
||||
|
||||
# Full rebuild (if something is really wrong)
|
||||
nix build --rebuild
|
||||
# Or directly from the container
|
||||
docker logs -f hermes-agent
|
||||
```
|
||||
|
||||
### Container inspection
|
||||
|
||||
```bash
|
||||
# Service status
|
||||
systemctl status hermes-agent
|
||||
|
||||
# Container state
|
||||
docker ps -a --filter name=hermes-agent
|
||||
docker inspect hermes-agent --format='{{.State.Status}}'
|
||||
|
||||
# Shell into the container
|
||||
docker exec -it hermes-agent bash
|
||||
|
||||
# Check symlink
|
||||
docker exec hermes-agent readlink /data/current-package
|
||||
|
||||
# Check identity hash
|
||||
docker exec hermes-agent cat /data/.container-identity
|
||||
```
|
||||
|
||||
### Force container recreation
|
||||
|
||||
If you need to reset the container writable layer (fresh Ubuntu):
|
||||
|
||||
```bash
|
||||
sudo systemctl stop hermes-agent
|
||||
docker rm -f hermes-agent
|
||||
sudo rm /var/lib/hermes/.container-identity
|
||||
sudo systemctl start hermes-agent
|
||||
# Container will be recreated from scratch
|
||||
```
|
||||
|
||||
### GC root verification
|
||||
|
||||
```bash
|
||||
# Ensure the running package is protected
|
||||
nix-store --query --roots $(docker exec hermes-agent readlink /data/current-package)
|
||||
```
|
||||
|
||||
### Common issues
|
||||
|
||||
| Symptom | Cause | Fix |
|
||||
|---|---|---|
|
||||
| `Cannot save configuration: managed by NixOS` | CLI guards active | Edit `configuration.nix` and `nixos-rebuild switch` |
|
||||
| Container recreated unexpectedly | `environment`, `environmentFiles`, `extraVolumes`, `extraOptions`, or `image` changed | Expected behavior — writable layer is reset. Reinstall packages if needed |
|
||||
| `hermes version` shows old version after rebuild | Container not restarted | `systemctl restart hermes-agent` |
|
||||
| Permission denied on `/var/lib/hermes` | State dir is `0750 hermes:hermes` | Use `docker exec` or `sudo -u hermes` |
|
||||
| `nix-collect-garbage` removed hermes | GC root missing or broken | Restart the service (`preStart` recreates the GC root) |
|
||||
|
|
|
|||
|
|
@ -27,8 +27,9 @@
|
|||
|
||||
imports = [
|
||||
./nix/packages.nix
|
||||
./nix/nixosModules.nix
|
||||
./nix/checks.nix
|
||||
./nix/devShell.nix
|
||||
./nix/homeModules.nix
|
||||
];
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,6 +46,32 @@ from hermes_cli.colors import Colors, color
|
|||
from hermes_cli.default_soul import DEFAULT_SOUL_MD
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Managed mode (NixOS declarative config)
|
||||
# =============================================================================
|
||||
|
||||
def is_managed() -> bool:
|
||||
"""Check if hermes is running in Nix-managed mode.
|
||||
|
||||
Two signals: the HERMES_MANAGED env var (set by the systemd service),
|
||||
or a .managed marker file in HERMES_HOME (set by the NixOS activation
|
||||
script, so interactive shells also see it).
|
||||
"""
|
||||
if os.getenv("HERMES_MANAGED", "").lower() in ("true", "1", "yes"):
|
||||
return True
|
||||
managed_marker = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes")) / ".managed"
|
||||
return managed_marker.exists()
|
||||
|
||||
def managed_error(action: str = "modify configuration"):
|
||||
"""Print user-friendly error for managed mode."""
|
||||
print(
|
||||
f"Cannot {action}: configuration is managed by NixOS (HERMES_MANAGED=true).\n"
|
||||
"Edit services.hermes-agent.settings in your configuration.nix and run:\n"
|
||||
" sudo nixos-rebuild switch",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Config paths
|
||||
# =============================================================================
|
||||
|
|
@ -1340,6 +1366,9 @@ _COMMENTED_SECTIONS = """
|
|||
|
||||
def save_config(config: Dict[str, Any]):
|
||||
"""Save configuration to ~/.hermes/config.yaml."""
|
||||
if is_managed():
|
||||
managed_error("save configuration")
|
||||
return
|
||||
from utils import atomic_yaml_write
|
||||
|
||||
ensure_hermes_home()
|
||||
|
|
@ -1481,6 +1510,9 @@ def sanitize_env_file() -> int:
|
|||
|
||||
def save_env_value(key: str, value: str):
|
||||
"""Save or update a value in ~/.hermes/.env."""
|
||||
if is_managed():
|
||||
managed_error(f"set {key}")
|
||||
return
|
||||
if not _ENV_VAR_NAME_RE.match(key):
|
||||
raise ValueError(f"Invalid environment variable name: {key!r}")
|
||||
value = value.replace("\n", "").replace("\r", "")
|
||||
|
|
@ -1737,6 +1769,9 @@ def show_config():
|
|||
|
||||
def edit_config():
|
||||
"""Open config file in user's editor."""
|
||||
if is_managed():
|
||||
managed_error("edit configuration")
|
||||
return
|
||||
config_path = get_config_path()
|
||||
|
||||
# Ensure config exists
|
||||
|
|
@ -1766,6 +1801,9 @@ def edit_config():
|
|||
|
||||
def set_config_value(key: str, value: str):
|
||||
"""Set a configuration value."""
|
||||
if is_managed():
|
||||
managed_error("set configuration values")
|
||||
return
|
||||
# Check if it's an API key (goes to .env)
|
||||
api_keys = [
|
||||
'OPENROUTER_API_KEY', 'OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'VOICE_TOOLS_OPENAI_KEY',
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ from pathlib import Path
|
|||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
|
||||
|
||||
from hermes_cli.config import get_env_value, get_hermes_home, save_env_value
|
||||
from hermes_cli.config import get_env_value, get_hermes_home, save_env_value, is_managed, managed_error
|
||||
from hermes_cli.setup import (
|
||||
print_header, print_info, print_success, print_warning, print_error,
|
||||
prompt, prompt_choice, prompt_yes_no,
|
||||
|
|
@ -1562,6 +1562,9 @@ def _setup_signal():
|
|||
|
||||
def gateway_setup():
|
||||
"""Interactive setup for messaging platforms + gateway service."""
|
||||
if is_managed():
|
||||
managed_error("run gateway setup")
|
||||
return
|
||||
|
||||
print()
|
||||
print(color("┌─────────────────────────────────────────────────────────┐", Colors.MAGENTA))
|
||||
|
|
@ -1716,6 +1719,9 @@ def gateway_command(args):
|
|||
|
||||
# Service management commands
|
||||
if subcmd == "install":
|
||||
if is_managed():
|
||||
managed_error("install gateway service (managed by NixOS)")
|
||||
return
|
||||
force = getattr(args, 'force', False)
|
||||
system = getattr(args, 'system', False)
|
||||
run_as_user = getattr(args, 'run_as_user', None)
|
||||
|
|
@ -1729,6 +1735,9 @@ def gateway_command(args):
|
|||
sys.exit(1)
|
||||
|
||||
elif subcmd == "uninstall":
|
||||
if is_managed():
|
||||
managed_error("uninstall gateway service (managed by NixOS)")
|
||||
return
|
||||
system = getattr(args, 'system', False)
|
||||
if is_linux():
|
||||
systemd_uninstall(system=system)
|
||||
|
|
|
|||
|
|
@ -3106,6 +3106,10 @@ def run_setup_wizard(args):
|
|||
hermes setup tools — just tool configuration
|
||||
hermes setup agent — just agent settings
|
||||
"""
|
||||
from hermes_cli.config import is_managed, managed_error
|
||||
if is_managed():
|
||||
managed_error("run setup wizard")
|
||||
return
|
||||
ensure_hermes_home()
|
||||
|
||||
config = load_config()
|
||||
|
|
|
|||
56
nix/checks.nix
Normal file
56
nix/checks.nix
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
# nix/checks.nix — Build-time verification tests
|
||||
{ inputs, ... }: {
|
||||
perSystem = { pkgs, system, ... }:
|
||||
let
|
||||
hermes-agent = inputs.self.packages.${system}.default;
|
||||
in {
|
||||
checks = {
|
||||
# Verify binaries exist and are executable
|
||||
package-contents = pkgs.runCommand "hermes-package-contents" { } ''
|
||||
set -e
|
||||
echo "=== Checking binaries ==="
|
||||
test -x ${hermes-agent}/bin/hermes || (echo "FAIL: hermes binary missing"; exit 1)
|
||||
test -x ${hermes-agent}/bin/hermes-agent || (echo "FAIL: hermes-agent binary missing"; exit 1)
|
||||
echo "PASS: All binaries present"
|
||||
|
||||
echo "=== Checking version ==="
|
||||
${hermes-agent}/bin/hermes version 2>&1 | grep -qi "hermes" || (echo "FAIL: version check"; exit 1)
|
||||
echo "PASS: Version check"
|
||||
|
||||
echo "=== All checks passed ==="
|
||||
mkdir -p $out
|
||||
echo "ok" > $out/result
|
||||
'';
|
||||
|
||||
# Verify CLI subcommands are accessible
|
||||
cli-commands = pkgs.runCommand "hermes-cli-commands" { } ''
|
||||
set -e
|
||||
export HOME=$(mktemp -d)
|
||||
|
||||
echo "=== Checking hermes --help ==="
|
||||
${hermes-agent}/bin/hermes --help 2>&1 | grep -q "gateway" || (echo "FAIL: gateway subcommand missing"; exit 1)
|
||||
${hermes-agent}/bin/hermes --help 2>&1 | grep -q "config" || (echo "FAIL: config subcommand missing"; exit 1)
|
||||
echo "PASS: All subcommands accessible"
|
||||
|
||||
echo "=== All CLI checks passed ==="
|
||||
mkdir -p $out
|
||||
echo "ok" > $out/result
|
||||
'';
|
||||
|
||||
# Verify HERMES_MANAGED guard works
|
||||
managed-guard = pkgs.runCommand "hermes-managed-guard" { } ''
|
||||
set -e
|
||||
export HOME=$(mktemp -d)
|
||||
|
||||
echo "=== Checking HERMES_MANAGED guard ==="
|
||||
OUTPUT=$(HERMES_MANAGED=true ${hermes-agent}/bin/hermes config set model foo 2>&1 || true)
|
||||
echo "$OUTPUT" | grep -q "managed by NixOS" || (echo "FAIL: managed guard not working"; echo "$OUTPUT"; exit 1)
|
||||
echo "PASS: Managed guard blocks config mutation"
|
||||
|
||||
echo "=== All guard checks passed ==="
|
||||
mkdir -p $out
|
||||
echo "ok" > $out/result
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
@ -1,93 +0,0 @@
|
|||
# nix/homeModules.nix — Home-manager module for hermes-agent
|
||||
{ inputs, ... }: {
|
||||
flake.homeManagerModules.default = { config, lib, pkgs, ... }:
|
||||
let
|
||||
cfg = config.services.hermes-agent;
|
||||
in {
|
||||
options.services.hermes-agent = {
|
||||
enable = lib.mkEnableOption "Hermes Agent AI assistant";
|
||||
|
||||
package = lib.mkOption {
|
||||
type = lib.types.package;
|
||||
default = inputs.self.packages.${pkgs.system}.default;
|
||||
defaultText = lib.literalExpression "hermes-agent.packages.\${pkgs.system}.default";
|
||||
description = "The hermes-agent package.";
|
||||
};
|
||||
|
||||
hermesHome = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "${config.home.homeDirectory}/.hermes";
|
||||
description = "Hermes config/sessions/memories directory.";
|
||||
};
|
||||
|
||||
environmentFile = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "${config.home.homeDirectory}/.hermes/.env";
|
||||
description = "Path to environment file containing API keys.";
|
||||
};
|
||||
|
||||
messagingCwd = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = config.home.homeDirectory;
|
||||
description = "Working directory for gateway messaging.";
|
||||
};
|
||||
|
||||
gateway = {
|
||||
enable = lib.mkEnableOption "Hermes Agent messaging gateway";
|
||||
};
|
||||
|
||||
addToPATH = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
description = "Add hermes CLI to PATH via home.packages.";
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
home.packages = lib.mkIf cfg.addToPATH [ cfg.package ];
|
||||
|
||||
# Lightweight activation: just ensure directory structure exists
|
||||
home.activation.hermesAgentSetup = lib.hm.dag.entryAfter [ "writeBoundary" ] ''
|
||||
mkdir -p "${cfg.hermesHome}"/{sessions,cron/output,logs,memories,skills}
|
||||
|
||||
if [ ! -f "${cfg.hermesHome}/config.yaml" ]; then
|
||||
cat > "${cfg.hermesHome}/config.yaml" << 'YAML'
|
||||
_config_version: 1
|
||||
model:
|
||||
default: "anthropic/claude-opus-4.6"
|
||||
terminal:
|
||||
env_type: "local"
|
||||
YAML
|
||||
fi
|
||||
'';
|
||||
|
||||
# Gateway systemd service
|
||||
systemd.user.services.hermes-agent-gateway = lib.mkIf cfg.gateway.enable {
|
||||
Unit = {
|
||||
Description = "Hermes Agent messaging gateway";
|
||||
After = [ "network-online.target" ];
|
||||
Wants = [ "network-online.target" ];
|
||||
StartLimitIntervalSec = 300;
|
||||
StartLimitBurst = 10;
|
||||
};
|
||||
|
||||
Service = {
|
||||
Type = "simple";
|
||||
ExecStart = "${cfg.package}/bin/hermes gateway run --replace";
|
||||
Restart = "on-failure";
|
||||
RestartSec = 15;
|
||||
Environment = [
|
||||
"HOME=${config.home.homeDirectory}"
|
||||
"HERMES_HOME=${cfg.hermesHome}"
|
||||
"MESSAGING_CWD=${cfg.messagingCwd}"
|
||||
];
|
||||
EnvironmentFile = cfg.environmentFile;
|
||||
};
|
||||
|
||||
Install = {
|
||||
WantedBy = [ "default.target" ];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
466
nix/nixosModules.nix
Normal file
466
nix/nixosModules.nix
Normal file
|
|
@ -0,0 +1,466 @@
|
|||
# nix/nixosModules.nix — NixOS module for hermes-agent
|
||||
#
|
||||
# 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/env/volume config changes
|
||||
# trigger container recreation.
|
||||
#
|
||||
# Usage:
|
||||
# services.hermes-agent = {
|
||||
# enable = true;
|
||||
# settings.model = "anthropic/claude-sonnet-4";
|
||||
# environmentFiles = [ config.sops.secrets."hermes/env".path ];
|
||||
# };
|
||||
#
|
||||
{ inputs, ... }: {
|
||||
flake.nixosModules.default = { config, lib, pkgs, ... }:
|
||||
|
||||
let
|
||||
cfg = config.services.hermes-agent;
|
||||
hermes-agent = inputs.self.packages.${pkgs.system}.default;
|
||||
|
||||
# Deep-merge config type (from 0xrsydn/nix-hermes-agent)
|
||||
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);
|
||||
};
|
||||
|
||||
# Generate config.yaml from Nix attrset (YAML is a superset of JSON)
|
||||
configJson = builtins.toJSON cfg.settings;
|
||||
generatedConfigFile = pkgs.writeText "hermes-config.yaml" configJson;
|
||||
configFile = if cfg.configFile != null then cfg.configFile else generatedConfigFile;
|
||||
|
||||
# Generate .env from non-secret environment attrset
|
||||
envFileContent = lib.concatStringsSep "\n" (
|
||||
lib.mapAttrsToList (k: v: "${k}=${v}") cfg.environment
|
||||
);
|
||||
generatedEnvFile = pkgs.writeText "hermes-env" envFileContent;
|
||||
|
||||
# Build documents derivation (from 0xrsydn)
|
||||
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
|
||||
)
|
||||
);
|
||||
|
||||
containerName = "hermes-agent";
|
||||
|
||||
# ── Container mode helpers ──────────────────────────────────────────
|
||||
containerBin = if cfg.container.backend == "docker"
|
||||
then "${pkgs.docker}/bin/docker"
|
||||
else "${pkgs.podman}/bin/podman";
|
||||
|
||||
# Identity hash — only recreate container when config changes
|
||||
containerIdentity = builtins.hashString "sha256" (builtins.toJSON {
|
||||
image = cfg.container.image;
|
||||
environment = cfg.environment;
|
||||
environmentFiles = cfg.environmentFiles;
|
||||
extraVolumes = cfg.container.extraVolumes;
|
||||
extraOptions = cfg.container.extraOptions;
|
||||
});
|
||||
|
||||
identityFile = "${cfg.stateDir}/.container-identity";
|
||||
|
||||
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 ─────────────────────────────────────────────────
|
||||
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).";
|
||||
};
|
||||
|
||||
workingDirectory = mkOption {
|
||||
type = types.str;
|
||||
default = "${cfg.stateDir}/workspace";
|
||||
defaultText = literalExpression ''"''${cfg.stateDir}/workspace"'';
|
||||
description = "Working directory for the agent (MESSAGING_CWD).";
|
||||
};
|
||||
|
||||
# ── Declarative config ───────────────────────────────────────────────
|
||||
configFile = mkOption {
|
||||
type = types.nullOr types.path;
|
||||
default = null;
|
||||
description = ''
|
||||
Path to an existing config.yaml. If set, takes precedence over
|
||||
the declarative `settings` option.
|
||||
'';
|
||||
};
|
||||
|
||||
settings = mkOption {
|
||||
type = deepConfigType;
|
||||
default = { };
|
||||
description = ''
|
||||
Declarative Hermes config (attrset). Deep-merged across module
|
||||
definitions and rendered as config.yaml.
|
||||
'';
|
||||
example = literalExpression ''
|
||||
{
|
||||
model = "anthropic/claude-sonnet-4";
|
||||
terminal.backend = "local";
|
||||
compression = { enabled = true; threshold = 0.85; };
|
||||
toolsets = [ "all" ];
|
||||
}
|
||||
'';
|
||||
};
|
||||
|
||||
# ── Secrets / environment ────────────────────────────────────────────
|
||||
environmentFiles = mkOption {
|
||||
type = types.listOf types.str;
|
||||
default = [ ];
|
||||
description = ''
|
||||
Paths to environment files containing secrets (API keys, tokens).
|
||||
Passed as systemd EnvironmentFile= entries.
|
||||
'';
|
||||
};
|
||||
|
||||
environment = mkOption {
|
||||
type = types.attrsOf types.str;
|
||||
default = { };
|
||||
description = ''
|
||||
Non-secret environment variables. Written to an env file visible
|
||||
in the Nix store — do NOT put secrets here.
|
||||
'';
|
||||
};
|
||||
|
||||
authFile = mkOption {
|
||||
type = types.nullOr types.path;
|
||||
default = null;
|
||||
description = ''
|
||||
Path to an auth.json seed file (OAuth credentials).
|
||||
Only copied on first deploy — existing auth.json is preserved.
|
||||
'';
|
||||
};
|
||||
|
||||
authFileForceOverwrite = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = "Always overwrite auth.json from authFile on activation.";
|
||||
};
|
||||
|
||||
# ── Documents ────────────────────────────────────────────────────────
|
||||
documents = mkOption {
|
||||
type = types.attrsOf (types.either types.str types.path);
|
||||
default = { };
|
||||
description = ''
|
||||
Workspace files (SOUL.md, USER.md, etc.). Keys are filenames,
|
||||
values are inline strings or paths. Installed into workingDirectory.
|
||||
'';
|
||||
example = literalExpression ''
|
||||
{
|
||||
"SOUL.md" = "You are a helpful AI assistant.";
|
||||
"USER.md" = ./documents/USER.md;
|
||||
}
|
||||
'';
|
||||
};
|
||||
|
||||
# ── 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 settings.mcp_servers).";
|
||||
};
|
||||
|
||||
# ── Service behavior ─────────────────────────────────────────────────
|
||||
extraArgs = mkOption {
|
||||
type = types.listOf types.str;
|
||||
default = [ ];
|
||||
description = "Extra command-line arguments for `hermes gateway`.";
|
||||
};
|
||||
|
||||
extraPackages = mkOption {
|
||||
type = types.listOf types.package;
|
||||
default = [ ];
|
||||
description = "Extra packages 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.";
|
||||
};
|
||||
|
||||
addToSystemPackages = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = "Add hermes CLI to environment.systemPackages.";
|
||||
};
|
||||
|
||||
# ── 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.";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable (lib.mkMerge [
|
||||
|
||||
# ── Merge MCP servers into settings ────────────────────────────────
|
||||
(lib.mkIf (cfg.mcpServers != { }) {
|
||||
services.hermes-agent.settings.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 ──────────────────────────────────────────────────
|
||||
(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 ──────────────────────────────────────────────────────
|
||||
(lib.mkIf cfg.addToSystemPackages {
|
||||
environment.systemPackages = [ cfg.package ];
|
||||
})
|
||||
|
||||
# ── Directories ───────────────────────────────────────────────────
|
||||
{
|
||||
systemd.tmpfiles.rules = [
|
||||
"d ${cfg.stateDir} 0750 ${cfg.user} ${cfg.group} - -"
|
||||
"d ${cfg.stateDir}/.hermes 0750 ${cfg.user} ${cfg.group} - -"
|
||||
"d ${cfg.workingDirectory} 0750 ${cfg.user} ${cfg.group} - -"
|
||||
];
|
||||
}
|
||||
|
||||
# ── Activation: link config + auth + documents ────────────────────
|
||||
{
|
||||
system.activationScripts."hermes-agent-setup" = lib.stringAfter [ "users" ] ''
|
||||
# Ensure directories exist (activation runs before tmpfiles)
|
||||
mkdir -p ${cfg.stateDir}/.hermes
|
||||
mkdir -p ${cfg.workingDirectory}
|
||||
chown ${cfg.user}:${cfg.group} ${cfg.stateDir} ${cfg.stateDir}/.hermes ${cfg.workingDirectory}
|
||||
|
||||
# Link config file
|
||||
install -o ${cfg.user} -g ${cfg.group} -m 0640 -D ${configFile} ${cfg.stateDir}/.hermes/config.yaml
|
||||
|
||||
# Managed mode marker (so interactive shells also detect NixOS management)
|
||||
touch ${cfg.stateDir}/.hermes/.managed
|
||||
chown ${cfg.user}:${cfg.group} ${cfg.stateDir}/.hermes/.managed
|
||||
|
||||
# Seed auth file if provided
|
||||
${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)}
|
||||
'';
|
||||
}
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════
|
||||
# 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" ];
|
||||
|
||||
environment = {
|
||||
HOME = cfg.stateDir;
|
||||
HERMES_HOME = "${cfg.stateDir}/.hermes";
|
||||
HERMES_MANAGED = "true";
|
||||
MESSAGING_CWD = cfg.workingDirectory;
|
||||
};
|
||||
|
||||
serviceConfig = {
|
||||
User = cfg.user;
|
||||
Group = cfg.group;
|
||||
WorkingDirectory = cfg.workingDirectory;
|
||||
|
||||
EnvironmentFile =
|
||||
lib.optional (cfg.environment != { }) generatedEnvFile
|
||||
++ cfg.environmentFiles;
|
||||
|
||||
ExecStart = lib.concatStringsSep " " ([
|
||||
"${cfg.package}/bin/hermes"
|
||||
"gateway"
|
||||
] ++ cfg.extraArgs);
|
||||
|
||||
Restart = cfg.restart;
|
||||
RestartSec = cfg.restartSec;
|
||||
|
||||
# Hardening
|
||||
NoNewPrivileges = true;
|
||||
ProtectSystem = "strict";
|
||||
ProtectHome = false;
|
||||
ReadWritePaths = [ cfg.stateDir ];
|
||||
PrivateTmp = true;
|
||||
};
|
||||
|
||||
path = [
|
||||
cfg.package
|
||||
pkgs.bash
|
||||
pkgs.coreutils
|
||||
pkgs.git
|
||||
] ++ cfg.extraPackages;
|
||||
};
|
||||
})
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════
|
||||
# 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 = ''
|
||||
# Update symlink to current hermes package
|
||||
ln -sfn ${cfg.package} ${cfg.stateDir}/current-package
|
||||
|
||||
# GC root so nix-collect-garbage doesn't remove the running package
|
||||
${pkgs.nix}/bin/nix-store --add-root ${cfg.stateDir}/.gc-root --indirect -r ${cfg.package} 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
|
||||
echo "Creating container..."
|
||||
${containerBin} create \
|
||||
--name ${containerName} \
|
||||
--network=host \
|
||||
--volume /nix/store:/nix/store:ro \
|
||||
--volume ${cfg.stateDir}:/data \
|
||||
${lib.concatStringsSep " " (map (v: "--volume ${v}") cfg.container.extraVolumes)} \
|
||||
--env HERMES_HOME=/data/.hermes \
|
||||
--env HERMES_MANAGED=true \
|
||||
--env HOME=/home/hermes \
|
||||
--env MESSAGING_CWD=/data/workspace \
|
||||
${lib.concatStringsSep " " (lib.mapAttrsToList (k: v: "--env ${k}=${v}") cfg.environment)} \
|
||||
${lib.concatStringsSep " " (map (f: "--env-file ${f}") cfg.environmentFiles)} \
|
||||
${lib.concatStringsSep " " cfg.container.extraOptions} \
|
||||
${cfg.container.image} \
|
||||
/data/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;
|
||||
};
|
||||
};
|
||||
})
|
||||
]);
|
||||
};
|
||||
}
|
||||
|
|
@ -11,21 +11,6 @@
|
|||
];
|
||||
|
||||
runtimePath = pkgs.lib.makeBinPath runtimeDeps;
|
||||
|
||||
# Submodules fetched declaratively (git deps break Nix sandbox)
|
||||
mini-swe-agent-src = pkgs.fetchFromGitHub {
|
||||
owner = "SWE-agent";
|
||||
repo = "mini-swe-agent";
|
||||
rev = "07aa6a738556e44b30d7b5c3bbd5063dac871d25";
|
||||
hash = "sha256-7+8dvi49iQMO4bXK5VYcem1+Tub5vMCrrZeNcEojAUQ=";
|
||||
};
|
||||
|
||||
tinker-atropos-src = pkgs.fetchFromGitHub {
|
||||
owner = "nousresearch";
|
||||
repo = "tinker-atropos";
|
||||
rev = "65f084ee8054a5d02aeac76e24ed60388511c82b";
|
||||
hash = "sha256-tD1VyUfMin+KnkQD+eyEibeJNe6d4dgB1b6wFe+3gKs=";
|
||||
};
|
||||
in {
|
||||
packages.default = pkgs.stdenv.mkDerivation {
|
||||
pname = "hermes-agent";
|
||||
|
|
@ -38,20 +23,13 @@
|
|||
installPhase = ''
|
||||
runHook preInstall
|
||||
|
||||
# Place submodule sources for runtime import
|
||||
mkdir -p $out/share/hermes-agent
|
||||
cp -r ${mini-swe-agent-src}/src/minisweagent $out/share/hermes-agent/minisweagent
|
||||
cp -r ${tinker-atropos-src} $out/share/hermes-agent/tinker-atropos
|
||||
|
||||
# Wrap entry points from the uv2nix venv
|
||||
mkdir -p $out/bin
|
||||
makeWrapper ${hermesVenv}/bin/hermes $out/bin/hermes \
|
||||
--prefix PATH : "${runtimePath}" \
|
||||
--prefix PYTHONPATH : $out/share/hermes-agent
|
||||
--prefix PATH : "${runtimePath}"
|
||||
|
||||
makeWrapper ${hermesVenv}/bin/hermes-agent $out/bin/hermes-agent \
|
||||
--prefix PATH : "${runtimePath}" \
|
||||
--prefix PYTHONPATH : $out/share/hermes-agent
|
||||
--prefix PATH : "${runtimePath}"
|
||||
|
||||
runHook postInstall
|
||||
'';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue