diff --git a/docs/nixos-setup.md b/docs/nixos-setup.md index fb34ad369f32..848e994ea585 100644 --- a/docs/nixos-setup.md +++ b/docs/nixos-setup.md @@ -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 ` | 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..command` | `str` | — | Server command | +| `mcpServers..args` | `listOf str` | `[]` | Command arguments | +| `mcpServers..env` | `attrsOf str` | `{}` | Server environment | +| `mcpServers..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) | diff --git a/flake.nix b/flake.nix index a08f9584ad4a..87be89c85c31 100644 --- a/flake.nix +++ b/flake.nix @@ -27,8 +27,9 @@ imports = [ ./nix/packages.nix + ./nix/nixosModules.nix + ./nix/checks.nix ./nix/devShell.nix - ./nix/homeModules.nix ]; }; } diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 000d8626b1a8..8c85ccc74cec 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -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', diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index b156c75e33b6..d1d7986719b4 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -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) diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index ff460ebc9eaf..ddb865293b67 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -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() diff --git a/nix/checks.nix b/nix/checks.nix new file mode 100644 index 000000000000..bb9b4b1a77df --- /dev/null +++ b/nix/checks.nix @@ -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 + ''; + }; + }; +} diff --git a/nix/homeModules.nix b/nix/homeModules.nix deleted file mode 100644 index 88ae5c9adc3e..000000000000 --- a/nix/homeModules.nix +++ /dev/null @@ -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" ]; - }; - }; - }; - }; -} diff --git a/nix/nixosModules.nix b/nix/nixosModules.nix new file mode 100644 index 000000000000..4fba6bee74a9 --- /dev/null +++ b/nix/nixosModules.nix @@ -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; + }; + }; + }) + ]); + }; +} diff --git a/nix/packages.nix b/nix/packages.nix index 5b0b84f55080..c5806a67c8ff 100644 --- a/nix/packages.nix +++ b/nix/packages.nix @@ -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 '';