Compare commits

..

26 Commits

Author SHA1 Message Date
alt-glitch
22a60ab474 fix(nix): preserve container writable layer across nixos-rebuild
The container identity hash included the entrypoint's Nix store path,
which changes on every nixpkgs update (due to runtimeShell/stdenv
input-addressing). This caused false-positive identity mismatches,
triggering container recreation and losing the persistent writable layer.

- Use stable symlink (current-entrypoint) like current-package already does
- Remove entrypoint from identity hash (only image/volumes/options matter)
- Add GC root for entrypoint so nix-collect-garbage doesn't break it
- Remove global HERMES_HOME env var from addToSystemPackages (conflicted
  with interactive CLI use, service already sets its own)
2026-03-25 23:47:28 +05:30
alt-glitch
06ef875477 fix(nix): skip flake check and build on macOS CI
onnxruntime (transitive dep via faster-whisper) lacks a compatible
uv2nix wheel on aarch64-darwin. Run full checks and build on Linux
only; macOS CI verifies the flake evaluates without building.
2026-03-25 12:07:08 +05:30
alt-glitch
3f35918988 fix(nix): skip checks on aarch64-darwin (onnxruntime wheel missing)
The full Python venv includes onnxruntime (via faster-whisper/STT)
which lacks a compatible uv2nix wheel on aarch64-darwin. Gate all
checks behind stdenv.hostPlatform.isLinux. The package and devShell
still evaluate on macOS.
2026-03-25 12:02:47 +05:30
alt-glitch
bc3686ef05 fix(nix): add compression.protect_last_n and target_ratio to config-keys.json
New keys were added to DEFAULT_CONFIG on main, causing the
config-drift check to fail in CI.
2026-03-25 11:47:16 +05:30
alt-glitch
08c1fea296 docs: remove docs/nixos-setup.md, consolidate into website docs
Backfill missing details (restart/restartSec in full example,
gateway.pid, 0750 permissions, docker inspect commands) into
the canonical website/docs/getting-started/nix-setup.md and
delete the old standalone file.
2026-03-25 11:41:00 +05:30
alt-glitch
ca38a51633 docs: add Nix & NixOS setup guide to docs site
Add comprehensive Nix documentation to the Docusaurus site at
website/docs/getting-started/nix-setup.md, covering nix run/profile
install, NixOS module (native + container modes), declarative settings,
secrets management, MCP servers, managed mode, container architecture,
dev shell, flake checks, and full options reference.

- Register nix-setup in sidebar after installation page
- Add Nix callout tip to installation.md linking to new guide
- Add canonical version pointer in docs/nixos-setup.md
2026-03-25 11:31:54 +05:30
alt-glitch
e9dd5685da feat(nix): persistent /home/hermes and MESSAGING_CWD in container mode
Container mode now bind-mounts ${stateDir}/home to /home/hermes so the
agent's home directory survives container recreation. Previously it lived
in the writable layer and was lost on image/volume/options changes.

Also passes MESSAGING_CWD to the container so the agent finds its
workspace and documents, matching native mode behavior.

Other changes:
- Extract containerDataDir/containerHomeDir bindings (no more magic strings)
- Fix entrypoint chown to run unconditionally (volume mounts always exist)
- Add schema field to container identity hash for auto-recreation
- Add idempotency test (Scenario G) to config-roundtrip check
2026-03-25 11:31:54 +05:30
alt-glitch
db39702110 fix group and user creation in container mode 2026-03-25 11:31:54 +05:30
alt-glitch
6f46c5596d feat(nix): container entrypoint with privilege drop and sudo provisioning
Container was running as non-root via --user, which broke apt/pip installs
and caused crashes when $HOME didn't exist. Replace --user with a Nix-built
entrypoint script that provisions the hermes user, sudo (NOPASSWD), and
/home/hermes inside the container on first boot, then drops privileges via
setpriv. Writable layer persists so setup only runs once.

Also expands MCP server options to support HTTP transport and sampling.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 11:31:54 +05:30
alt-glitch
211bf795cf fix reading .env. instead have container user a common mounted .env file 2026-03-25 11:31:54 +05:30
alt-glitch
76135a8222 Update MCP server package name; bundled skills support 2026-03-25 11:31:54 +05:30
alt-glitch
3611074096 feat(nix): add CI workflow and enhanced build checks
- GitHub Actions workflow for nix flake check + build on linux/macOS
- Entry point sync check to catch pyproject.toml drift
- Expanded managed-guard check to cover config edit
- Wrap hermes-acp binary in Nix package
- Fix Path type mismatch in is_managed()
2026-03-25 11:31:54 +05:30
alt-glitch
8c475752be Update config.py 2026-03-25 11:31:54 +05:30
alt-glitch
b51a5b201e 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>
2026-03-25 11:31:54 +05:30
balyan.sid@gmail.com
1e8fae283f fixed nix run, updated docs for setup 2026-03-25 11:31:54 +05:30
balyan.sid@gmail.com
63b583aa2f feat: nix flake, uv2nix build, dev shell and home manager 2026-03-25 11:31:54 +05:30
Teknium
e5691eed38 feat(gateway): configurable Telegram reply threading mode (#2907)
Add reply_to_mode setting (off/first/all) to control whether Telegram
replies quote/thread to the user's original message.

- 'off': Never thread replies (no quote bubble)
- 'first': Only first chunk threads to user's message (default, preserves existing behavior)
- 'all': All chunks in multi-part replies thread to user's message

Configurable via:
- reply_to_mode in platform config (gateway config YAML)
- TELEGRAM_REPLY_TO_MODE env var

Based on PR #855 by raulvidis.
2026-03-24 19:56:00 -07:00
Teknium
ab4ba8163a feat(migration): comprehensive OpenClaw migration v2 — 17 new modules, terminal recap (#2906)
* feat(migration): comprehensive OpenClaw -> Hermes migration v2

Extends the existing migration script from ~15% to ~95% coverage of
OpenClaw's configuration surface. Adds 17 new migration modules:

Direct migrations (written to config.yaml/.env):
- MCP servers: full server definitions with transport, tools, sampling
- Agent defaults: reasoning_effort, compression, human_delay, timezone
- Session config: reset triggers (daily/idle) -> session_reset
- Full model providers: custom_providers with base_url/api_mode
- Deep channel config: Matrix, Mattermost, IRC, Discord deep settings
- Browser config: timeout settings
- Tools config: exec timeout -> terminal.timeout
- Approvals: mode mapping (smart/manual/auto -> Hermes equivalents)

Archived for manual review (no direct Hermes equivalent):
- Plugins config + installed extensions
- Cron jobs (with note to use 'hermes cron')
- Hooks/webhooks config
- Multi-agent list + routing bindings
- Gateway config (port, auth, TLS)
- Memory backend config (QMD, vector search)
- Skills registry per-entry config
- UI/identity settings
- Logging/diagnostics preferences

Also adds:
- MIGRATION_NOTES.md generation with PM2 reassurance message
- _set_env_var helper for consistent env file management
- Updated presets to include all new options
- Comprehensive mock test passing (12 migrated, 12 archived)

* feat(migration): add terminal recap with visual summary

Replaces raw JSON dump with a formatted box showing migrated/archived/
skipped/conflict/error counts, detailed item lists with labels, PM2
reassurance message, and actionable next steps. JSON output available
via MIGRATION_JSON_OUTPUT=1 env var.

* fix(test): allowlist python_os_environ as known false-positive in skills guard test

MIGRATION_JSON_OUTPUT env var is a legitimate CLI feature flag that enables
JSON output mode, not an env dump. Add it alongside agent_config_mod as an
accepted finding in test_skill_installs_cleanly_under_skills_guard.

* fix(test): add hermes_config_mod to known false-positives in skills guard test

The scanner flags two print statements that tell the user to *review*
~/.hermes/config.yaml in the post-migration summary. The script never
writes to that file — those are informational strings, not config mutations.

---------

Co-authored-by: Hermes <hermes@nousresearch.ai>
2026-03-24 19:44:02 -07:00
Teknium
80cc27eb9d feat(api-server): Idempotency-Key support, body size limit, OpenAI error envelope (#2903)
* feat(api-server): add Idempotency-Key support and request size limit; unify OpenAI error envelope

* fix(api-server): include provider error message in 500 OpenAI error body

---------

Co-authored-by: aydnOktay <xaydinoktay@gmail.com>
2026-03-24 19:31:08 -07:00
Teknium
1b24a226ea fix(skills): agent-created skills were incorrectly treated as untrusted community content
_resolve_trust_level() didn't handle 'agent-created' source, so it
fell through to 'community' trust level. Community policy blocks on
any caution or dangerous findings, which meant common patterns like
curl with env vars, systemctl, crontab, cloudflared references etc.
would block skill creation/patching.

The agent-created policy row already existed in INSTALL_POLICY with
permissive settings (allow caution, ask on dangerous) but was never
reached. Now it is.

Fixes reports of skill_manage being blocked by security scanner.
2026-03-24 19:15:03 -07:00
Teknium
9b32f846a8 fix: browser_vision ignores auxiliary.vision.timeout config (#2901)
* docs: unify hooks documentation — add plugin hooks to hooks page, add session:end event

The hooks page only documented gateway event hooks (HOOK.yaml system).
The plugins page listed plugin hooks (pre_tool_call, etc.) that weren't
referenced from the hooks page, which was confusing.

Changes:
- hooks.md: Add overview table showing both hook systems
- hooks.md: Add Plugin Hooks section with available hooks, callback
  signatures, and example
- hooks.md: Add missing session:end gateway event (emitted but undocumented)
- hooks.md: Mark pre_llm_call, post_llm_call, on_session_start,
  on_session_end as planned (defined in VALID_HOOKS but not yet invoked)
- hooks.md: Update info box to cross-reference plugin hooks
- hooks.md: Fix heading hierarchy (gateway content as subsections)
- plugins.md: Add cross-reference to hooks page for full details
- plugins.md: Mark planned hooks as (planned)

* fix: browser_vision ignores auxiliary.vision.timeout config

browser_vision called call_llm() without passing a timeout parameter,
so it always used the 30-second default in auxiliary_client.py. This
made vision analysis with local models (llama.cpp, ollama) impossible
since they typically need more than 30s for screenshot analysis.

Now browser_vision reads auxiliary.vision.timeout from config.yaml
(same config key that vision_analyze already uses) and passes it
through to call_llm().

Also bumped the default vision timeout from 30s to 120s in both
browser_vision and vision_analyze — 30s is too aggressive for local
models and the previous default silently failed for anyone running
vision locally.

Fixes user report from GamerGB1988.
2026-03-24 19:10:12 -07:00
Teknium
7ca22ea11b fix(compression): restore sane defaults and cap summary at 12K tokens
- threshold: 0.80 → 0.50 (compress at 50%, not 80%)
- target_ratio: 0.40 → 0.20, now relative to threshold not total context
  (20% of 50% = 10% of context as tail budget)
- summary ceiling: 32K → 12K (Gemini can't output more than ~12K)
- Updated DEFAULT_CONFIG, config display, example config, and tests
2026-03-24 18:48:47 -07:00
Teknium
ef47531617 docs: unify hooks documentation — add plugin hooks to hooks page, add session:end event
The hooks page only documented gateway event hooks (HOOK.yaml system).
The plugins page listed plugin hooks (pre_tool_call, etc.) that weren't
referenced from the hooks page, which was confusing.

Changes:
- hooks.md: Add overview table showing both hook systems
- hooks.md: Add Plugin Hooks section with available hooks, callback
  signatures, and example
- hooks.md: Add missing session:end gateway event (emitted but undocumented)
- hooks.md: Mark pre_llm_call, post_llm_call, on_session_start,
  on_session_end as planned (defined in VALID_HOOKS but not yet invoked)
- hooks.md: Update info box to cross-reference plugin hooks
- hooks.md: Fix heading hierarchy (gateway content as subsections)
- plugins.md: Add cross-reference to hooks page for full details
- plugins.md: Mark planned hooks as (planned)
2026-03-24 18:48:47 -07:00
Teknium
b36fe9282a feat(session_search): add recent sessions mode when query is omitted (#2533)
feat(session_search): add recent sessions mode when query is omitted
2026-03-24 18:41:38 -07:00
Teknium
1e9ff53a74 docs: clarify two-mode behavior in session_search schema description 2026-03-24 18:08:06 -07:00
Teknium
e93b539a8f feat(session_search): add recent sessions mode when query is omitted
When session_search is called without a query (or with an empty query),
it now returns metadata for the most recent sessions instead of erroring.
This lets the agent quickly see what was worked on recently without
needing specific keywords.

Returns for each session: session_id, title, source, started_at,
last_active, message_count, preview (first user message).
Zero LLM cost — pure DB query. Current session lineage and child
delegation sessions are excluded.

The agent can then keyword-search specific sessions if it needs
deeper context from any of them.
2026-03-22 11:22:10 -07:00
28 changed files with 3973 additions and 159 deletions

1
.envrc Normal file
View File

@@ -0,0 +1 @@
use flake

40
.github/workflows/nix.yml vendored Normal file
View File

@@ -0,0 +1,40 @@
name: Nix
on:
push:
branches: [main]
pull_request:
paths:
- 'flake.nix'
- 'flake.lock'
- 'nix/**'
- 'pyproject.toml'
- 'uv.lock'
- 'hermes_cli/**'
- 'run_agent.py'
- 'acp_adapter/**'
concurrency:
group: nix-${{ github.ref }}
cancel-in-progress: true
jobs:
nix:
strategy:
matrix:
os: [ubuntu-latest, macos-latest]
runs-on: ${{ matrix.os }}
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: DeterminateSystems/nix-installer-action@main
- uses: DeterminateSystems/magic-nix-cache-action@main
- name: Check flake
if: runner.os == 'Linux'
run: nix flake check --print-build-logs
- name: Build package
if: runner.os == 'Linux'
run: nix build --print-build-logs
- name: Evaluate flake (macOS)
if: runner.os == 'macOS'
run: nix flake show --json > /dev/null

4
.gitignore vendored
View File

@@ -54,3 +54,7 @@ environments/benchmarks/evals/
# Release script temp files
.release_notes.md
mini-swe-agent/
# Nix
.direnv/
result

6
cli.py
View File

@@ -1509,14 +1509,10 @@ class HermesCLI:
self._reasoning_buf = getattr(self, "_reasoning_buf", "") + text
# Emit complete lines, and force-flush long partial lines so
# reasoning is visible in real-time even without newlines.
# Emit complete lines
while "\n" in self._reasoning_buf:
line, self._reasoning_buf = self._reasoning_buf.split("\n", 1)
_cprint(f"{_DIM}{line}{_RST}")
if len(self._reasoning_buf) > 80:
_cprint(f"{_DIM}{self._reasoning_buf}{_RST}")
self._reasoning_buf = ""
def _close_reasoning_box(self) -> None:
"""Close the live reasoning box if it's open."""

181
flake.lock generated Normal file
View File

@@ -0,0 +1,181 @@
{
"nodes": {
"flake-parts": {
"inputs": {
"nixpkgs-lib": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1772408722,
"narHash": "sha256-rHuJtdcOjK7rAHpHphUb1iCvgkU3GpfvicLMwwnfMT0=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "f20dc5d9b8027381c474144ecabc9034d6a839a3",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "flake-parts",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1751274312,
"narHash": "sha256-/bVBlRpECLVzjV19t5KMdMFWSwKLtb5RyXdjz3LJT+g=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "50ab793786d9de88ee30ec4e4c24fb4236fc2674",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-24.11",
"repo": "nixpkgs",
"type": "github"
}
},
"pyproject-build-systems": {
"inputs": {
"nixpkgs": [
"nixpkgs"
],
"pyproject-nix": "pyproject-nix",
"uv2nix": "uv2nix"
},
"locked": {
"lastModified": 1772555609,
"narHash": "sha256-3BA3HnUvJSbHJAlJj6XSy0Jmu7RyP2gyB/0fL7XuEDo=",
"owner": "pyproject-nix",
"repo": "build-system-pkgs",
"rev": "c37f66a953535c394244888598947679af231863",
"type": "github"
},
"original": {
"owner": "pyproject-nix",
"repo": "build-system-pkgs",
"type": "github"
}
},
"pyproject-nix": {
"inputs": {
"nixpkgs": [
"pyproject-build-systems",
"nixpkgs"
]
},
"locked": {
"lastModified": 1769936401,
"narHash": "sha256-kwCOegKLZJM9v/e/7cqwg1p/YjjTAukKPqmxKnAZRgA=",
"owner": "nix-community",
"repo": "pyproject.nix",
"rev": "b0d513eeeebed6d45b4f2e874f9afba2021f7812",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "pyproject.nix",
"type": "github"
}
},
"pyproject-nix_2": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1772865871,
"narHash": "sha256-/ZTSg97aouL0SlPHaokA4r3iuH9QzHVuWPACD2CUCFY=",
"owner": "pyproject-nix",
"repo": "pyproject.nix",
"rev": "e537db02e72d553cea470976b9733581bcf5b3ed",
"type": "github"
},
"original": {
"owner": "pyproject-nix",
"repo": "pyproject.nix",
"type": "github"
}
},
"pyproject-nix_3": {
"inputs": {
"nixpkgs": [
"uv2nix",
"nixpkgs"
]
},
"locked": {
"lastModified": 1771518446,
"narHash": "sha256-nFJSfD89vWTu92KyuJWDoTQJuoDuddkJV3TlOl1cOic=",
"owner": "pyproject-nix",
"repo": "pyproject.nix",
"rev": "eb204c6b3335698dec6c7fc1da0ebc3c6df05937",
"type": "github"
},
"original": {
"owner": "pyproject-nix",
"repo": "pyproject.nix",
"type": "github"
}
},
"root": {
"inputs": {
"flake-parts": "flake-parts",
"nixpkgs": "nixpkgs",
"pyproject-build-systems": "pyproject-build-systems",
"pyproject-nix": "pyproject-nix_2",
"uv2nix": "uv2nix_2"
}
},
"uv2nix": {
"inputs": {
"nixpkgs": [
"pyproject-build-systems",
"nixpkgs"
],
"pyproject-nix": [
"pyproject-build-systems",
"pyproject-nix"
]
},
"locked": {
"lastModified": 1770770348,
"narHash": "sha256-A2GzkmzdYvdgmMEu5yxW+xhossP+txrYb7RuzRaqhlg=",
"owner": "pyproject-nix",
"repo": "uv2nix",
"rev": "5d1b2cb4fe3158043fbafbbe2e46238abbc954b0",
"type": "github"
},
"original": {
"owner": "pyproject-nix",
"repo": "uv2nix",
"type": "github"
}
},
"uv2nix_2": {
"inputs": {
"nixpkgs": [
"nixpkgs"
],
"pyproject-nix": "pyproject-nix_3"
},
"locked": {
"lastModified": 1773039484,
"narHash": "sha256-+boo33KYkJDw9KItpeEXXv8+65f7hHv/earxpcyzQ0I=",
"owner": "pyproject-nix",
"repo": "uv2nix",
"rev": "b68be7cfeacbed9a3fa38a2b5adc0cfb81d9bb1f",
"type": "github"
},
"original": {
"owner": "pyproject-nix",
"repo": "uv2nix",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

35
flake.nix Normal file
View File

@@ -0,0 +1,35 @@
{
description = "Hermes Agent - AI agent framework by Nous Research";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11";
flake-parts = {
url = "github:hercules-ci/flake-parts";
inputs.nixpkgs-lib.follows = "nixpkgs";
};
pyproject-nix = {
url = "github:pyproject-nix/pyproject.nix";
inputs.nixpkgs.follows = "nixpkgs";
};
uv2nix = {
url = "github:pyproject-nix/uv2nix";
inputs.nixpkgs.follows = "nixpkgs";
};
pyproject-build-systems = {
url = "github:pyproject-nix/build-system-pkgs";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs = inputs:
inputs.flake-parts.lib.mkFlake { inherit inputs; } {
systems = [ "x86_64-linux" "aarch64-linux" "aarch64-darwin" ];
imports = [
./nix/packages.nix
./nix/nixosModules.nix
./nix/checks.nix
./nix/devShell.nix
];
};
}

View File

@@ -138,6 +138,12 @@ class PlatformConfig:
api_key: Optional[str] = None # API key if different from token
home_channel: Optional[HomeChannel] = None
# Reply threading mode (Telegram/Slack)
# - "off": Never thread replies to original message
# - "first": Only first chunk threads to user's message (default)
# - "all": All chunks in multi-part replies thread to user's message
reply_to_mode: str = "first"
# Platform-specific settings
extra: Dict[str, Any] = field(default_factory=dict)
@@ -145,6 +151,7 @@ class PlatformConfig:
result = {
"enabled": self.enabled,
"extra": self.extra,
"reply_to_mode": self.reply_to_mode,
}
if self.token:
result["token"] = self.token
@@ -165,6 +172,7 @@ class PlatformConfig:
token=data.get("token"),
api_key=data.get("api_key"),
home_channel=home_channel,
reply_to_mode=data.get("reply_to_mode", "first"),
extra=data.get("extra", {}),
)
@@ -586,6 +594,13 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
config.platforms[Platform.TELEGRAM].enabled = True
config.platforms[Platform.TELEGRAM].token = telegram_token
# Reply threading mode for Telegram (off/first/all)
telegram_reply_mode = os.getenv("TELEGRAM_REPLY_TO_MODE", "").lower()
if telegram_reply_mode in ("off", "first", "all"):
if Platform.TELEGRAM not in config.platforms:
config.platforms[Platform.TELEGRAM] = PlatformConfig()
config.platforms[Platform.TELEGRAM].reply_to_mode = telegram_reply_mode
telegram_home = os.getenv("TELEGRAM_HOME_CHANNEL")
if telegram_home and Platform.TELEGRAM in config.platforms:
config.platforms[Platform.TELEGRAM].home_channel = HomeChannel(

View File

@@ -45,6 +45,7 @@ logger = logging.getLogger(__name__)
DEFAULT_HOST = "127.0.0.1"
DEFAULT_PORT = 8642
MAX_STORED_RESPONSES = 100
MAX_REQUEST_BYTES = 1_000_000 # 1 MB default limit for POST bodies
def check_api_server_requirements() -> bool:
@@ -194,6 +195,73 @@ else:
cors_middleware = None # type: ignore[assignment]
def _openai_error(message: str, err_type: str = "invalid_request_error", param: str = None, code: str = None) -> Dict[str, Any]:
"""OpenAI-style error envelope."""
return {
"error": {
"message": message,
"type": err_type,
"param": param,
"code": code,
}
}
if AIOHTTP_AVAILABLE:
@web.middleware
async def body_limit_middleware(request, handler):
"""Reject overly large request bodies early based on Content-Length."""
if request.method in ("POST", "PUT", "PATCH"):
cl = request.headers.get("Content-Length")
if cl is not None:
try:
if int(cl) > MAX_REQUEST_BYTES:
return web.json_response(_openai_error("Request body too large.", code="body_too_large"), status=413)
except ValueError:
return web.json_response(_openai_error("Invalid Content-Length header.", code="invalid_content_length"), status=400)
return await handler(request)
else:
body_limit_middleware = None # type: ignore[assignment]
class _IdempotencyCache:
"""In-memory idempotency cache with TTL and basic LRU semantics."""
def __init__(self, max_items: int = 1000, ttl_seconds: int = 300):
from collections import OrderedDict
self._store = OrderedDict()
self._ttl = ttl_seconds
self._max = max_items
def _purge(self):
import time as _t
now = _t.time()
expired = [k for k, v in self._store.items() if now - v["ts"] > self._ttl]
for k in expired:
self._store.pop(k, None)
while len(self._store) > self._max:
self._store.popitem(last=False)
async def get_or_set(self, key: str, fingerprint: str, compute_coro):
self._purge()
item = self._store.get(key)
if item and item["fp"] == fingerprint:
return item["resp"]
resp = await compute_coro()
import time as _t
self._store[key] = {"resp": resp, "fp": fingerprint, "ts": _t.time()}
self._purge()
return resp
_idem_cache = _IdempotencyCache()
def _make_request_fingerprint(body: Dict[str, Any], keys: List[str]) -> str:
from hashlib import sha256
subset = {k: body.get(k) for k in keys}
return sha256(repr(subset).encode("utf-8")).hexdigest()
class APIServerAdapter(BasePlatformAdapter):
"""
OpenAI-compatible HTTP API server adapter.
@@ -360,10 +428,7 @@ class APIServerAdapter(BasePlatformAdapter):
try:
body = await request.json()
except (json.JSONDecodeError, Exception):
return web.json_response(
{"error": {"message": "Invalid JSON in request body", "type": "invalid_request_error"}},
status=400,
)
return web.json_response(_openai_error("Invalid JSON in request body"), status=400)
messages = body.get("messages")
if not messages or not isinstance(messages, list):
@@ -428,20 +493,35 @@ class APIServerAdapter(BasePlatformAdapter):
request, completion_id, model_name, created, _stream_q, agent_task
)
# Non-streaming: run the agent and return full response
try:
result, usage = await self._run_agent(
# Non-streaming: run the agent (with optional Idempotency-Key)
async def _compute_completion():
return await self._run_agent(
user_message=user_message,
conversation_history=history,
ephemeral_system_prompt=system_prompt,
session_id=session_id,
)
except Exception as e:
logger.error("Error running agent for chat completions: %s", e, exc_info=True)
return web.json_response(
{"error": {"message": f"Internal server error: {e}", "type": "server_error"}},
status=500,
)
idempotency_key = request.headers.get("Idempotency-Key")
if idempotency_key:
fp = _make_request_fingerprint(body, keys=["model", "messages", "tools", "tool_choice", "stream"])
try:
result, usage = await _idem_cache.get_or_set(idempotency_key, fp, _compute_completion)
except Exception as e:
logger.error("Error running agent for chat completions: %s", e, exc_info=True)
return web.json_response(
_openai_error(f"Internal server error: {e}", err_type="server_error"),
status=500,
)
else:
try:
result, usage = await _compute_completion()
except Exception as e:
logger.error("Error running agent for chat completions: %s", e, exc_info=True)
return web.json_response(
_openai_error(f"Internal server error: {e}", err_type="server_error"),
status=500,
)
final_response = result.get("final_response", "")
if not final_response:
@@ -567,10 +647,7 @@ class APIServerAdapter(BasePlatformAdapter):
raw_input = body.get("input")
if raw_input is None:
return web.json_response(
{"error": {"message": "Missing 'input' field", "type": "invalid_request_error"}},
status=400,
)
return web.json_response(_openai_error("Missing 'input' field"), status=400)
instructions = body.get("instructions")
previous_response_id = body.get("previous_response_id")
@@ -579,10 +656,7 @@ class APIServerAdapter(BasePlatformAdapter):
# conversation and previous_response_id are mutually exclusive
if conversation and previous_response_id:
return web.json_response(
{"error": {"message": "Cannot use both 'conversation' and 'previous_response_id'", "type": "invalid_request_error"}},
status=400,
)
return web.json_response(_openai_error("Cannot use both 'conversation' and 'previous_response_id'"), status=400)
# Resolve conversation name to latest response_id
if conversation:
@@ -613,20 +687,14 @@ class APIServerAdapter(BasePlatformAdapter):
content = "\n".join(text_parts)
input_messages.append({"role": role, "content": content})
else:
return web.json_response(
{"error": {"message": "'input' must be a string or array", "type": "invalid_request_error"}},
status=400,
)
return web.json_response(_openai_error("'input' must be a string or array"), status=400)
# Reconstruct conversation history from previous_response_id
conversation_history: List[Dict[str, str]] = []
if previous_response_id:
stored = self._response_store.get(previous_response_id)
if stored is None:
return web.json_response(
{"error": {"message": f"Previous response not found: {previous_response_id}", "type": "invalid_request_error"}},
status=404,
)
return web.json_response(_openai_error(f"Previous response not found: {previous_response_id}"), status=404)
conversation_history = list(stored.get("conversation_history", []))
# If no instructions provided, carry forward from previous
if instructions is None:
@@ -639,30 +707,46 @@ class APIServerAdapter(BasePlatformAdapter):
# Last input message is the user_message
user_message = input_messages[-1].get("content", "") if input_messages else ""
if not user_message:
return web.json_response(
{"error": {"message": "No user message found in input", "type": "invalid_request_error"}},
status=400,
)
return web.json_response(_openai_error("No user message found in input"), status=400)
# Truncation support
if body.get("truncation") == "auto" and len(conversation_history) > 100:
conversation_history = conversation_history[-100:]
# Run the agent
# Run the agent (with Idempotency-Key support)
session_id = str(uuid.uuid4())
try:
result, usage = await self._run_agent(
async def _compute_response():
return await self._run_agent(
user_message=user_message,
conversation_history=conversation_history,
ephemeral_system_prompt=instructions,
session_id=session_id,
)
except Exception as e:
logger.error("Error running agent for responses: %s", e, exc_info=True)
return web.json_response(
{"error": {"message": f"Internal server error: {e}", "type": "server_error"}},
status=500,
idempotency_key = request.headers.get("Idempotency-Key")
if idempotency_key:
fp = _make_request_fingerprint(
body,
keys=["input", "instructions", "previous_response_id", "conversation", "model", "tools"],
)
try:
result, usage = await _idem_cache.get_or_set(idempotency_key, fp, _compute_response)
except Exception as e:
logger.error("Error running agent for responses: %s", e, exc_info=True)
return web.json_response(
_openai_error(f"Internal server error: {e}", err_type="server_error"),
status=500,
)
else:
try:
result, usage = await _compute_response()
except Exception as e:
logger.error("Error running agent for responses: %s", e, exc_info=True)
return web.json_response(
_openai_error(f"Internal server error: {e}", err_type="server_error"),
status=500,
)
final_response = result.get("final_response", "")
if not final_response:
@@ -726,10 +810,7 @@ class APIServerAdapter(BasePlatformAdapter):
response_id = request.match_info["response_id"]
stored = self._response_store.get(response_id)
if stored is None:
return web.json_response(
{"error": {"message": f"Response not found: {response_id}", "type": "invalid_request_error"}},
status=404,
)
return web.json_response(_openai_error(f"Response not found: {response_id}"), status=404)
return web.json_response(stored["response"])
@@ -742,10 +823,7 @@ class APIServerAdapter(BasePlatformAdapter):
response_id = request.match_info["response_id"]
deleted = self._response_store.delete(response_id)
if not deleted:
return web.json_response(
{"error": {"message": f"Response not found: {response_id}", "type": "invalid_request_error"}},
status=404,
)
return web.json_response(_openai_error(f"Response not found: {response_id}"), status=404)
return web.json_response({
"id": response_id,
@@ -1090,7 +1168,8 @@ class APIServerAdapter(BasePlatformAdapter):
return False
try:
self._app = web.Application(middlewares=[cors_middleware])
mws = [mw for mw in (cors_middleware, body_limit_middleware) if mw is not None]
self._app = web.Application(middlewares=mws)
self._app["api_server_adapter"] = self
self._app.router.add_get("/health", self._handle_health)
self._app.router.add_get("/v1/models", self._handle_models)

View File

@@ -115,6 +115,7 @@ class TelegramAdapter(BasePlatformAdapter):
super().__init__(config, Platform.TELEGRAM)
self._app: Optional[Application] = None
self._bot: Optional[Bot] = None
self._reply_to_mode: str = getattr(config, 'reply_to_mode', 'first') or 'first'
# Buffer rapid/album photo updates so Telegram image bursts are handled
# as a single MessageEvent instead of self-interrupting multiple turns.
self._media_batch_delay_seconds = float(os.getenv("HERMES_TELEGRAM_MEDIA_BATCH_DELAY_SECONDS", "0.8"))
@@ -442,6 +443,26 @@ class TelegramAdapter(BasePlatformAdapter):
self._token_lock_identity = None
logger.info("[%s] Disconnected from Telegram", self.name)
def _should_thread_reply(self, reply_to: Optional[str], chunk_index: int) -> bool:
"""Determine if this message chunk should thread to the original message.
Args:
reply_to: The original message ID to reply to
chunk_index: Index of this chunk (0 = first chunk)
Returns:
True if this chunk should be threaded to the original message
"""
if not reply_to:
return False
mode = self._reply_to_mode
if mode == "off":
return False
elif mode == "all":
return True
else: # "first" (default)
return chunk_index == 0
async def send(
self,
chat_id: str,
@@ -475,6 +496,9 @@ class TelegramAdapter(BasePlatformAdapter):
_NetErr = OSError # type: ignore[misc,assignment]
for i, chunk in enumerate(chunks):
should_thread = self._should_thread_reply(reply_to, i)
reply_to_id = int(reply_to) if should_thread else None
msg = None
for _send_attempt in range(3):
try:
@@ -484,7 +508,7 @@ class TelegramAdapter(BasePlatformAdapter):
chat_id=int(chat_id),
text=chunk,
parse_mode=ParseMode.MARKDOWN_V2,
reply_to_message_id=int(reply_to) if reply_to and i == 0 else None,
reply_to_message_id=reply_to_id,
message_thread_id=int(thread_id) if thread_id else None,
)
except Exception as md_error:
@@ -496,7 +520,7 @@ class TelegramAdapter(BasePlatformAdapter):
chat_id=int(chat_id),
text=plain_chunk,
parse_mode=None,
reply_to_message_id=int(reply_to) if reply_to and i == 0 else None,
reply_to_message_id=reply_to_id,
message_thread_id=int(thread_id) if thread_id else None,
)
else:

View File

@@ -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", str(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',

View File

@@ -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)

View File

@@ -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()

376
nix/checks.nix Normal file
View File

@@ -0,0 +1,376 @@
# nix/checks.nix — Build-time verification tests
#
# Checks are Linux-only: the full Python venv (via uv2nix) includes
# transitive deps like onnxruntime that lack compatible wheels on
# aarch64-darwin. The package and devShell still work on macOS.
{ inputs, ... }: {
perSystem = { pkgs, system, lib, ... }:
let
hermes-agent = inputs.self.packages.${system}.default;
hermesVenv = pkgs.callPackage ./python.nix {
inherit (inputs) uv2nix pyproject-nix pyproject-build-systems;
};
configMergeScript = pkgs.callPackage ./configMergeScript.nix { };
in {
checks = lib.optionalAttrs pkgs.stdenv.hostPlatform.isLinux {
# 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 every pyproject.toml [project.scripts] entry has a wrapped binary
entry-points-sync = pkgs.runCommand "hermes-entry-points-sync" { } ''
set -e
echo "=== Checking entry points match pyproject.toml [project.scripts] ==="
for bin in hermes hermes-agent hermes-acp; do
test -x ${hermes-agent}/bin/$bin || (echo "FAIL: $bin binary missing from Nix package"; exit 1)
echo "PASS: $bin present"
done
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 bundled skills are present in the package
bundled-skills = pkgs.runCommand "hermes-bundled-skills" { } ''
set -e
echo "=== Checking bundled skills ==="
test -d ${hermes-agent}/share/hermes-agent/skills || (echo "FAIL: skills directory missing"; exit 1)
echo "PASS: skills directory exists"
SKILL_COUNT=$(find ${hermes-agent}/share/hermes-agent/skills -name "SKILL.md" | wc -l)
test "$SKILL_COUNT" -gt 0 || (echo "FAIL: no SKILL.md files found in skills directory"; exit 1)
echo "PASS: $SKILL_COUNT bundled skills found"
grep -q "HERMES_BUNDLED_SKILLS" ${hermes-agent}/bin/hermes || \
(echo "FAIL: HERMES_BUNDLED_SKILLS not in wrapper"; exit 1)
echo "PASS: HERMES_BUNDLED_SKILLS set in wrapper"
echo "=== All bundled skills checks passed ==="
mkdir -p $out
echo "ok" > $out/result
'';
# Verify HERMES_MANAGED guard works on all mutation commands
managed-guard = pkgs.runCommand "hermes-managed-guard" { } ''
set -e
export HOME=$(mktemp -d)
check_blocked() {
local label="$1"
shift
OUTPUT=$(HERMES_MANAGED=true "$@" 2>&1 || true)
echo "$OUTPUT" | grep -q "managed by NixOS" || (echo "FAIL: $label not guarded"; echo "$OUTPUT"; exit 1)
echo "PASS: $label blocked in managed mode"
}
echo "=== Checking HERMES_MANAGED guards ==="
check_blocked "config set" ${hermes-agent}/bin/hermes config set model foo
check_blocked "config edit" ${hermes-agent}/bin/hermes config edit
echo "=== All guard checks passed ==="
mkdir -p $out
echo "ok" > $out/result
'';
# ── Config drift detection ────────────────────────────────────────
# Extracts leaf key paths from Python's DEFAULT_CONFIG and compares
# against the committed reference in nix/config-keys.json.
config-drift = pkgs.runCommand "hermes-config-drift" {
nativeBuildInputs = [ pkgs.jq ];
referenceKeys = ./config-keys.json;
} ''
set -e
export HOME=$(mktemp -d)
echo "=== Extracting DEFAULT_CONFIG leaf keys from Python ==="
${hermesVenv}/bin/python3 -c '
import json, sys
from hermes_cli.config import DEFAULT_CONFIG
def leaf_paths(d, prefix=""):
paths = []
for k, v in sorted(d.items()):
path = f"{prefix}.{k}" if prefix else k
if isinstance(v, dict) and v:
paths.extend(leaf_paths(v, path))
else:
paths.append(path)
return paths
json.dump(sorted(leaf_paths(DEFAULT_CONFIG)), sys.stdout)
' > /tmp/actual-keys.json
echo "=== Comparing against reference ==="
jq -r '.[]' $referenceKeys | sort > /tmp/reference.txt
jq -r '.[]' /tmp/actual-keys.json | sort > /tmp/actual.txt
ADDED=$(comm -23 /tmp/actual.txt /tmp/reference.txt || true)
REMOVED=$(comm -13 /tmp/actual.txt /tmp/reference.txt || true)
FAILED=false
if [ -n "$ADDED" ]; then
echo "FAIL: New keys in DEFAULT_CONFIG not in nix/config-keys.json:"
echo "$ADDED" | sed 's/^/ + /'
FAILED=true
fi
if [ -n "$REMOVED" ]; then
echo "FAIL: Keys in nix/config-keys.json missing from DEFAULT_CONFIG:"
echo "$REMOVED" | sed 's/^/ - /'
FAILED=true
fi
if [ "$FAILED" = "true" ]; then
exit 1
fi
ACTUAL_COUNT=$(wc -l < /tmp/actual.txt)
echo "PASS: All $ACTUAL_COUNT config keys match reference"
mkdir -p $out
echo "ok" > $out/result
'';
# ── Config merge + round-trip test ────────────────────────────────
# Tests the merge script (Nix activation behavior) across 7
# scenarios, then verifies Python's load_config() reads correctly.
config-roundtrip = let
# Nix settings used across scenarios
nixSettings = pkgs.writeText "nix-settings.json" (builtins.toJSON {
model = "test/nix-model";
toolsets = ["nix-toolset"];
terminal = { backend = "docker"; timeout = 999; };
mcp_servers = {
nix-server = { command = "echo"; args = ["nix"]; };
};
});
# Pre-built YAML fixtures for each scenario
fixtureB = pkgs.writeText "fixture-b.yaml" ''
model: "old-model"
mcp_servers:
old-server:
url: "http://old"
'';
fixtureC = pkgs.writeText "fixture-c.yaml" ''
skills:
disabled:
- skill-a
- skill-b
session_reset:
mode: idle
idle_minutes: 30
streaming:
enabled: true
fallback_model:
provider: openrouter
model: test-fallback
'';
fixtureD = pkgs.writeText "fixture-d.yaml" ''
model: "user-model"
skills:
disabled:
- skill-x
streaming:
enabled: true
transport: edit
'';
fixtureE = pkgs.writeText "fixture-e.yaml" ''
mcp_servers:
user-server:
url: "http://user-mcp"
nix-server:
command: "old-cmd"
args: ["old"]
'';
fixtureF = pkgs.writeText "fixture-f.yaml" ''
terminal:
cwd: "/user/path"
custom_key: "preserved"
env_passthrough:
- USER_VAR
'';
in pkgs.runCommand "hermes-config-roundtrip" {
nativeBuildInputs = [ pkgs.jq ];
} ''
set -e
export HOME=$(mktemp -d)
ERRORS=""
fail() { ERRORS="$ERRORS\nFAIL: $1"; }
# Helper: run merge then load with Python, output merged JSON
merge_and_load() {
local hermes_home="$1"
export HERMES_HOME="$hermes_home"
${configMergeScript} ${nixSettings} "$hermes_home/config.yaml"
${hermesVenv}/bin/python3 -c '
import json, sys
from hermes_cli.config import load_config
json.dump(load_config(), sys.stdout, default=str)
'
}
#
# Scenario A: Fresh install no existing config.yaml
#
echo "=== Scenario A: Fresh install ==="
A_HOME=$(mktemp -d)
A_CONFIG=$(merge_and_load "$A_HOME")
echo "$A_CONFIG" | jq -e '.model == "test/nix-model"' > /dev/null \
|| fail "A: model not set from Nix"
echo "$A_CONFIG" | jq -e '.mcp_servers."nix-server".command == "echo"' > /dev/null \
|| fail "A: MCP nix-server missing"
echo "PASS: Scenario A"
#
# Scenario B: Nix keys override existing values
#
echo "=== Scenario B: Nix overrides ==="
B_HOME=$(mktemp -d)
install -m 0644 ${fixtureB} "$B_HOME/config.yaml"
B_CONFIG=$(merge_and_load "$B_HOME")
echo "$B_CONFIG" | jq -e '.model == "test/nix-model"' > /dev/null \
|| fail "B: Nix model did not override"
echo "PASS: Scenario B"
#
# Scenario C: User-only keys preserved
#
echo "=== Scenario C: User keys preserved ==="
C_HOME=$(mktemp -d)
install -m 0644 ${fixtureC} "$C_HOME/config.yaml"
C_CONFIG=$(merge_and_load "$C_HOME")
echo "$C_CONFIG" | jq -e '.skills.disabled == ["skill-a", "skill-b"]' > /dev/null \
|| fail "C: skills.disabled not preserved"
echo "$C_CONFIG" | jq -e '.session_reset.mode == "idle"' > /dev/null \
|| fail "C: session_reset.mode not preserved"
echo "$C_CONFIG" | jq -e '.session_reset.idle_minutes == 30' > /dev/null \
|| fail "C: session_reset.idle_minutes not preserved"
echo "$C_CONFIG" | jq -e '.streaming.enabled == true' > /dev/null \
|| fail "C: streaming.enabled not preserved"
echo "$C_CONFIG" | jq -e '.fallback_model.provider == "openrouter"' > /dev/null \
|| fail "C: fallback_model not preserved"
echo "PASS: Scenario C"
#
# Scenario D: Mixed Nix wins for its keys, user keys preserved
#
echo "=== Scenario D: Mixed merge ==="
D_HOME=$(mktemp -d)
install -m 0644 ${fixtureD} "$D_HOME/config.yaml"
D_CONFIG=$(merge_and_load "$D_HOME")
echo "$D_CONFIG" | jq -e '.model == "test/nix-model"' > /dev/null \
|| fail "D: Nix model did not override user model"
echo "$D_CONFIG" | jq -e '.skills.disabled == ["skill-x"]' > /dev/null \
|| fail "D: user skills not preserved"
echo "$D_CONFIG" | jq -e '.streaming.enabled == true' > /dev/null \
|| fail "D: user streaming not preserved"
echo "PASS: Scenario D"
#
# Scenario E: MCP additive merge
#
echo "=== Scenario E: MCP additive merge ==="
E_HOME=$(mktemp -d)
install -m 0644 ${fixtureE} "$E_HOME/config.yaml"
E_CONFIG=$(merge_and_load "$E_HOME")
echo "$E_CONFIG" | jq -e '.mcp_servers."user-server".url == "http://user-mcp"' > /dev/null \
|| fail "E: user MCP server not preserved"
echo "$E_CONFIG" | jq -e '.mcp_servers."nix-server".command == "echo"' > /dev/null \
|| fail "E: Nix MCP server did not override same-name user server"
echo "$E_CONFIG" | jq -e '.mcp_servers."nix-server".args == ["nix"]' > /dev/null \
|| fail "E: Nix MCP server args wrong"
echo "PASS: Scenario E"
#
# Scenario F: Nested deep merge
#
echo "=== Scenario F: Nested deep merge ==="
F_HOME=$(mktemp -d)
install -m 0644 ${fixtureF} "$F_HOME/config.yaml"
F_CONFIG=$(merge_and_load "$F_HOME")
echo "$F_CONFIG" | jq -e '.terminal.backend == "docker"' > /dev/null \
|| fail "F: Nix terminal.backend did not override"
echo "$F_CONFIG" | jq -e '.terminal.timeout == 999' > /dev/null \
|| fail "F: Nix terminal.timeout did not override"
echo "$F_CONFIG" | jq -e '.terminal.custom_key == "preserved"' > /dev/null \
|| fail "F: terminal.custom_key not preserved"
echo "$F_CONFIG" | jq -e '.terminal.cwd == "/user/path"' > /dev/null \
|| fail "F: user terminal.cwd not preserved when Nix does not set it"
echo "$F_CONFIG" | jq -e '.terminal.env_passthrough == ["USER_VAR"]' > /dev/null \
|| fail "F: user terminal.env_passthrough not preserved"
echo "PASS: Scenario F"
#
# Scenario G: Idempotency merging twice yields the same result
#
echo "=== Scenario G: Idempotency ==="
G_HOME=$(mktemp -d)
install -m 0644 ${fixtureD} "$G_HOME/config.yaml"
${configMergeScript} ${nixSettings} "$G_HOME/config.yaml"
FIRST=$(cat "$G_HOME/config.yaml")
${configMergeScript} ${nixSettings} "$G_HOME/config.yaml"
SECOND=$(cat "$G_HOME/config.yaml")
if [ "$FIRST" != "$SECOND" ]; then
fail "G: second merge produced different output"
echo "--- first ---"
echo "$FIRST"
echo "--- second ---"
echo "$SECOND"
fi
echo "PASS: Scenario G"
#
# Report
#
if [ -n "$ERRORS" ]; then
echo ""
echo "FAILURES:"
echo -e "$ERRORS"
exit 1
fi
echo ""
echo "=== All 7 merge scenarios passed ==="
mkdir -p $out
echo "ok" > $out/result
'';
};
};
}

129
nix/config-keys.json Normal file
View File

@@ -0,0 +1,129 @@
[
"_config_version",
"agent.max_turns",
"approvals.mode",
"auxiliary.approval.api_key",
"auxiliary.approval.base_url",
"auxiliary.approval.model",
"auxiliary.approval.provider",
"auxiliary.compression.api_key",
"auxiliary.compression.base_url",
"auxiliary.compression.model",
"auxiliary.compression.provider",
"auxiliary.flush_memories.api_key",
"auxiliary.flush_memories.base_url",
"auxiliary.flush_memories.model",
"auxiliary.flush_memories.provider",
"auxiliary.mcp.api_key",
"auxiliary.mcp.base_url",
"auxiliary.mcp.model",
"auxiliary.mcp.provider",
"auxiliary.session_search.api_key",
"auxiliary.session_search.base_url",
"auxiliary.session_search.model",
"auxiliary.session_search.provider",
"auxiliary.skills_hub.api_key",
"auxiliary.skills_hub.base_url",
"auxiliary.skills_hub.model",
"auxiliary.skills_hub.provider",
"auxiliary.vision.api_key",
"auxiliary.vision.base_url",
"auxiliary.vision.model",
"auxiliary.vision.provider",
"auxiliary.vision.timeout",
"auxiliary.web_extract.api_key",
"auxiliary.web_extract.base_url",
"auxiliary.web_extract.model",
"auxiliary.web_extract.provider",
"browser.command_timeout",
"browser.inactivity_timeout",
"browser.record_sessions",
"checkpoints.enabled",
"checkpoints.max_snapshots",
"command_allowlist",
"compression.enabled",
"compression.protect_last_n",
"compression.summary_base_url",
"compression.summary_model",
"compression.summary_provider",
"compression.target_ratio",
"compression.threshold",
"delegation.api_key",
"delegation.base_url",
"delegation.model",
"delegation.provider",
"discord.auto_thread",
"discord.free_response_channels",
"discord.require_mention",
"display.bell_on_complete",
"display.compact",
"display.personality",
"display.resume_display",
"display.show_cost",
"display.show_reasoning",
"display.skin",
"display.streaming",
"honcho",
"human_delay.max_ms",
"human_delay.min_ms",
"human_delay.mode",
"memory.memory_char_limit",
"memory.memory_enabled",
"memory.user_char_limit",
"memory.user_profile_enabled",
"model",
"personalities",
"prefill_messages_file",
"privacy.redact_pii",
"quick_commands",
"security.redact_secrets",
"security.tirith_enabled",
"security.tirith_fail_open",
"security.tirith_path",
"security.tirith_timeout",
"security.website_blocklist.domains",
"security.website_blocklist.enabled",
"security.website_blocklist.shared_files",
"smart_model_routing.cheap_model",
"smart_model_routing.enabled",
"smart_model_routing.max_simple_chars",
"smart_model_routing.max_simple_words",
"stt.enabled",
"stt.local.model",
"stt.openai.model",
"stt.provider",
"terminal.backend",
"terminal.container_cpu",
"terminal.container_disk",
"terminal.container_memory",
"terminal.container_persistent",
"terminal.cwd",
"terminal.daytona_image",
"terminal.docker_forward_env",
"terminal.docker_image",
"terminal.docker_mount_cwd_to_workspace",
"terminal.docker_volumes",
"terminal.env_passthrough",
"terminal.modal_image",
"terminal.persistent_shell",
"terminal.singularity_image",
"terminal.timeout",
"timezone",
"toolsets",
"tts.edge.voice",
"tts.elevenlabs.model_id",
"tts.elevenlabs.voice_id",
"tts.neutts.device",
"tts.neutts.model",
"tts.neutts.ref_audio",
"tts.neutts.ref_text",
"tts.openai.model",
"tts.openai.voice",
"tts.provider",
"voice.auto_tts",
"voice.max_recording_seconds",
"voice.record_key",
"voice.silence_duration",
"voice.silence_threshold",
"whatsapp"
]

33
nix/configMergeScript.nix Normal file
View File

@@ -0,0 +1,33 @@
# nix/configMergeScript.nix — Deep-merge Nix settings into existing config.yaml
#
# Used by the NixOS module activation script and by checks.nix tests.
# Nix keys override; user-added keys (skills, streaming, etc.) are preserved.
{ pkgs }:
pkgs.writeScript "hermes-config-merge" ''
#!${pkgs.python3.withPackages (ps: [ ps.pyyaml ])}/bin/python3
import json, yaml, sys
from pathlib import Path
nix_json, config_path = sys.argv[1], Path(sys.argv[2])
with open(nix_json) as f:
nix = json.load(f)
existing = {}
if config_path.exists():
with open(config_path) as f:
existing = yaml.safe_load(f) or {}
def deep_merge(base, override):
result = dict(base)
for k, v in override.items():
if k in result and isinstance(result[k], dict) and isinstance(v, dict):
result[k] = deep_merge(result[k], v)
else:
result[k] = v
return result
merged = deep_merge(existing, nix)
with open(config_path, "w") as f:
yaml.dump(merged, f, default_flow_style=False, sort_keys=False)
''

51
nix/devShell.nix Normal file
View File

@@ -0,0 +1,51 @@
# nix/devShell.nix — Fast dev shell with stamp-file optimization
{ inputs, ... }: {
perSystem = { pkgs, ... }:
let
python = pkgs.python311;
in {
devShells.default = pkgs.mkShell {
packages = with pkgs; [
python uv nodejs_20 ripgrep git openssh ffmpeg
];
shellHook = ''
echo "Hermes Agent dev shell"
# Composite stamp: changes when nix python or uv change
STAMP_VALUE="${python}:${pkgs.uv}"
STAMP_FILE=".venv/.nix-stamp"
# Create venv if missing
if [ ! -d .venv ]; then
echo "Creating Python 3.11 venv..."
uv venv .venv --python ${python}/bin/python3
fi
source .venv/bin/activate
# Only install if stamp is stale or missing
if [ ! -f "$STAMP_FILE" ] || [ "$(cat "$STAMP_FILE")" != "$STAMP_VALUE" ]; then
echo "Installing Python dependencies..."
uv pip install -e ".[all]"
if [ -d mini-swe-agent ]; then
uv pip install -e ./mini-swe-agent 2>/dev/null || true
fi
if [ -d tinker-atropos ]; then
uv pip install -e ./tinker-atropos 2>/dev/null || true
fi
# Install npm deps
if [ -f package.json ] && [ ! -d node_modules ]; then
echo "Installing npm dependencies..."
npm install
fi
echo "$STAMP_VALUE" > "$STAMP_FILE"
fi
echo "Ready. Run 'hermes' to start."
'';
};
};
}

716
nix/nixosModules.nix Normal file
View File

@@ -0,0 +1,716 @@
# 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/volume/options changes trigger
# container recreation. Environment variables are written to $HERMES_HOME/.env
# and read by hermes at startup — no container recreation needed for env changes.
#
# 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;
configMergeScript = pkgs.callPackage ./configMergeScript.nix { };
# Generate .env from non-secret environment attrset
envFileContent = lib.concatStringsSep "\n" (
lib.mapAttrsToList (k: v: "${k}=${v}") cfg.environment
);
# 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";
containerDataDir = "/data"; # stateDir mount point inside container
containerHomeDir = "/home/hermes";
# ── Container mode helpers ──────────────────────────────────────────
containerBin = if cfg.container.backend == "docker"
then "${pkgs.docker}/bin/docker"
else "${pkgs.podman}/bin/podman";
# Runs as root inside the container on every start. Provisions the
# hermes user + sudo on first boot (writable layer persists), then
# drops privileges. Supports arbitrary base images (Debian, Alpine, etc).
containerEntrypoint = pkgs.writeShellScript "hermes-container-entrypoint" ''
set -eu
HERMES_UID="''${HERMES_UID:?HERMES_UID must be set}"
HERMES_GID="''${HERMES_GID:?HERMES_GID must be set}"
# Group: ensure a group with GID=$HERMES_GID exists
# Check by GID (not name) to avoid collisions with pre-existing groups
# (e.g. GID 100 = "users" on Ubuntu)
EXISTING_GROUP=$(getent group "$HERMES_GID" 2>/dev/null | cut -d: -f1 || true)
if [ -n "$EXISTING_GROUP" ]; then
GROUP_NAME="$EXISTING_GROUP"
else
GROUP_NAME="hermes"
if command -v groupadd >/dev/null 2>&1; then
groupadd -g "$HERMES_GID" "$GROUP_NAME"
elif command -v addgroup >/dev/null 2>&1; then
addgroup -g "$HERMES_GID" "$GROUP_NAME" 2>/dev/null || true
fi
fi
# User: ensure a user with UID=$HERMES_UID exists
PASSWD_ENTRY=$(getent passwd "$HERMES_UID" 2>/dev/null || true)
if [ -n "$PASSWD_ENTRY" ]; then
TARGET_USER=$(echo "$PASSWD_ENTRY" | cut -d: -f1)
TARGET_HOME=$(echo "$PASSWD_ENTRY" | cut -d: -f6)
else
TARGET_USER="hermes"
TARGET_HOME="/home/hermes"
if command -v useradd >/dev/null 2>&1; then
useradd -u "$HERMES_UID" -g "$HERMES_GID" -m -d "$TARGET_HOME" -s /bin/bash "$TARGET_USER"
elif command -v adduser >/dev/null 2>&1; then
adduser -u "$HERMES_UID" -D -h "$TARGET_HOME" -s /bin/sh -G "$GROUP_NAME" "$TARGET_USER" 2>/dev/null || true
fi
fi
mkdir -p "$TARGET_HOME"
chown "$HERMES_UID:$HERMES_GID" "$TARGET_HOME"
# Ensure HERMES_HOME is owned by the target user
if [ -n "''${HERMES_HOME:-}" ] && [ -d "$HERMES_HOME" ]; then
chown -R "$HERMES_UID:$HERMES_GID" "$HERMES_HOME"
fi
# Install sudo on Debian/Ubuntu if missing (first boot only, cached in writable layer)
if command -v apt-get >/dev/null 2>&1 && ! command -v sudo >/dev/null 2>&1; then
apt-get update -qq >/dev/null 2>&1 && apt-get install -y -qq sudo >/dev/null 2>&1 || true
fi
if command -v sudo >/dev/null 2>&1 && [ ! -f /etc/sudoers.d/hermes ]; then
mkdir -p /etc/sudoers.d
echo "$TARGET_USER ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/hermes
chmod 0440 /etc/sudoers.d/hermes
fi
if command -v setpriv >/dev/null 2>&1; then
exec setpriv --reuid="$HERMES_UID" --regid="$HERMES_GID" --init-groups "$@"
elif command -v su >/dev/null 2>&1; then
exec su -s /bin/sh "$TARGET_USER" -c 'exec "$0" "$@"' -- "$@"
else
echo "WARNING: no privilege-drop tool (setpriv/su), running as root" >&2
exec "$@"
fi
'';
# Identity hash — only recreate container when structural config changes.
# Package and entrypoint use stable symlinks (current-package, current-entrypoint)
# so they can update without recreation. Env vars go through $HERMES_HOME/.env.
containerIdentity = builtins.hashString "sha256" (builtins.toJSON {
schema = 3; # bump when identity inputs change
image = cfg.container.image;
extraVolumes = cfg.container.extraVolumes;
extraOptions = cfg.container.extraOptions;
});
identityFile = "${cfg.stateDir}/.container-identity";
# Default: /var/lib/hermes/workspace → /data/workspace.
# Custom paths outside stateDir pass through unchanged (user must add extraVolumes).
containerWorkDir =
if lib.hasPrefix "${cfg.stateDir}/" cfg.workingDirectory
then "${containerDataDir}/${lib.removePrefix "${cfg.stateDir}/" cfg.workingDirectory}"
else cfg.workingDirectory;
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).
Contents are merged into $HERMES_HOME/.env at activation time.
Hermes reads this file on every startup via load_hermes_dotenv().
'';
};
environment = mkOption {
type = types.attrsOf types.str;
default = { };
description = ''
Non-secret environment variables. Merged into $HERMES_HOME/.env
at activation time. Do NOT put secrets here use environmentFiles.
'';
};
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 = {
# Stdio transport
command = mkOption {
type = types.nullOr types.str;
default = null;
description = "MCP server command (stdio transport).";
};
args = mkOption {
type = types.listOf types.str;
default = [ ];
description = "Command-line arguments (stdio transport).";
};
env = mkOption {
type = types.attrsOf types.str;
default = { };
description = "Environment variables for the server process (stdio transport).";
};
# HTTP/StreamableHTTP transport
url = mkOption {
type = types.nullOr types.str;
default = null;
description = "MCP server endpoint URL (HTTP/StreamableHTTP transport).";
};
headers = mkOption {
type = types.attrsOf types.str;
default = { };
description = "HTTP headers, e.g. for authentication (HTTP transport).";
};
# Authentication
auth = mkOption {
type = types.nullOr (types.enum [ "oauth" ]);
default = null;
description = ''
Authentication method. Set to "oauth" for OAuth 2.1 PKCE flow
(remote MCP servers). Tokens are stored in $HERMES_HOME/mcp-tokens/.
'';
};
# Enable/disable
enabled = mkOption {
type = types.bool;
default = true;
description = "Enable or disable this MCP server.";
};
# Common options
timeout = mkOption {
type = types.nullOr types.int;
default = null;
description = "Tool call timeout in seconds (default: 120).";
};
connect_timeout = mkOption {
type = types.nullOr types.int;
default = null;
description = "Initial connection timeout in seconds (default: 60).";
};
# Tool filtering
tools = mkOption {
type = types.nullOr (types.submodule {
options = {
include = mkOption {
type = types.listOf types.str;
default = [ ];
description = "Tool allowlist only these tools are registered.";
};
exclude = mkOption {
type = types.listOf types.str;
default = [ ];
description = "Tool blocklist these tools are hidden.";
};
};
});
default = null;
description = "Filter which tools are exposed by this server.";
};
# Sampling (server-initiated LLM requests)
sampling = mkOption {
type = types.nullOr (types.submodule {
options = {
enabled = mkOption { type = types.bool; default = true; description = "Enable sampling."; };
model = mkOption { type = types.nullOr types.str; default = null; description = "Override model for sampling requests."; };
max_tokens_cap = mkOption { type = types.nullOr types.int; default = null; description = "Max tokens per request."; };
timeout = mkOption { type = types.nullOr types.int; default = null; description = "LLM call timeout in seconds."; };
max_rpm = mkOption { type = types.nullOr types.int; default = null; description = "Max requests per minute."; };
max_tool_rounds = mkOption { type = types.nullOr types.int; default = null; description = "Max tool-use rounds per sampling request."; };
allowed_models = mkOption { type = types.listOf types.str; default = [ ]; description = "Models the server is allowed to request."; };
log_level = mkOption {
type = types.nullOr (types.enum [ "debug" "info" "warning" ]);
default = null;
description = "Audit log level for sampling requests.";
};
};
});
default = null;
description = "Sampling configuration for server-initiated LLM requests.";
};
};
});
default = { };
description = ''
MCP server configurations (merged into settings.mcp_servers).
Each server uses either stdio (command/args) or HTTP (url) transport.
'';
example = literalExpression ''
{
filesystem = {
command = "npx";
args = [ "-y" "@modelcontextprotocol/server-filesystem" "/home/user" ];
};
remote-api = {
url = "http://my-server:8080/v0/mcp";
headers = { Authorization = "Bearer ..."; };
};
remote-oauth = {
url = "https://mcp.example.com/mcp";
auth = "oauth";
};
}
'';
};
# ── 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:
# Stdio transport
lib.optionalAttrs (srv.command != null) { inherit (srv) command args; }
// lib.optionalAttrs (srv.env != { }) { inherit (srv) env; }
# HTTP transport
// lib.optionalAttrs (srv.url != null) { inherit (srv) url; }
// lib.optionalAttrs (srv.headers != { }) { inherit (srv) headers; }
# Auth
// lib.optionalAttrs (srv.auth != null) { inherit (srv) auth; }
# Enable/disable
// { inherit (srv) enabled; }
# Common options
// lib.optionalAttrs (srv.timeout != null) { inherit (srv) timeout; }
// lib.optionalAttrs (srv.connect_timeout != null) { inherit (srv) connect_timeout; }
# Tool filtering
// lib.optionalAttrs (srv.tools != null) {
tools = lib.filterAttrs (_: v: v != [ ]) {
inherit (srv.tools) include exclude;
};
}
# Sampling
// lib.optionalAttrs (srv.sampling != null) {
sampling = lib.filterAttrs (_: v: v != null && v != [ ]) {
inherit (srv.sampling) enabled model max_tokens_cap timeout max_rpm
max_tool_rounds allowed_models log_level;
};
}
) 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} 0755 ${cfg.user} ${cfg.group} - -"
"d ${cfg.stateDir}/.hermes 0755 ${cfg.user} ${cfg.group} - -"
"d ${cfg.stateDir}/home 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.stateDir}/home
mkdir -p ${cfg.workingDirectory}
chown ${cfg.user}:${cfg.group} ${cfg.stateDir} ${cfg.stateDir}/.hermes ${cfg.stateDir}/home ${cfg.workingDirectory}
# Merge Nix settings into existing config.yaml.
# Preserves user-added keys (skills, streaming, etc.); Nix keys win.
# If configFile is user-provided (not generated), overwrite instead of merge.
${if cfg.configFile != null then ''
install -o ${cfg.user} -g ${cfg.group} -m 0644 -D ${configFile} ${cfg.stateDir}/.hermes/config.yaml
'' else ''
${configMergeScript} ${generatedConfigFile} ${cfg.stateDir}/.hermes/config.yaml
chown ${cfg.user}:${cfg.group} ${cfg.stateDir}/.hermes/config.yaml
chmod 0644 ${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
''}
''}
# Seed .env from Nix-declared environment + environmentFiles.
# Hermes reads $HERMES_HOME/.env at startup via load_hermes_dotenv(),
# so this is the single source of truth for both native and container mode.
${lib.optionalString (cfg.environment != {} || cfg.environmentFiles != []) ''
ENV_FILE="${cfg.stateDir}/.hermes/.env"
install -o ${cfg.user} -g ${cfg.group} -m 0600 /dev/null "$ENV_FILE"
cat > "$ENV_FILE" <<'HERMES_NIX_ENV_EOF'
${envFileContent}
HERMES_NIX_ENV_EOF
${lib.concatStringsSep "\n" (map (f: ''
if [ -f "${f}" ]; then
echo "" >> "$ENV_FILE"
cat "${f}" >> "$ENV_FILE"
fi
'') cfg.environmentFiles)}
''}
# 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;
# cfg.environment and cfg.environmentFiles are written to
# $HERMES_HOME/.env by the activation script. load_hermes_dotenv()
# reads them at Python startup — no systemd EnvironmentFile needed.
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 = ''
# Stable symlinks container references these, not store paths directly
ln -sfn ${cfg.package} ${cfg.stateDir}/current-package
ln -sfn ${containerEntrypoint} ${cfg.stateDir}/current-entrypoint
# GC roots so nix-collect-garbage doesn't remove store paths in use
${pkgs.nix}/bin/nix-store --add-root ${cfg.stateDir}/.gc-root --indirect -r ${cfg.package} 2>/dev/null || true
${pkgs.nix}/bin/nix-store --add-root ${cfg.stateDir}/.gc-root-entrypoint --indirect -r ${containerEntrypoint} 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
# Resolve numeric UID/GID passed to entrypoint for in-container user setup
HERMES_UID=$(${pkgs.coreutils}/bin/id -u ${cfg.user})
HERMES_GID=$(${pkgs.coreutils}/bin/id -g ${cfg.user})
echo "Creating container..."
${containerBin} create \
--name ${containerName} \
--network=host \
--entrypoint ${containerDataDir}/current-entrypoint \
--volume /nix/store:/nix/store:ro \
--volume ${cfg.stateDir}:${containerDataDir} \
--volume ${cfg.stateDir}/home:${containerHomeDir} \
${lib.concatStringsSep " " (map (v: "--volume ${v}") cfg.container.extraVolumes)} \
--env HERMES_UID="$HERMES_UID" \
--env HERMES_GID="$HERMES_GID" \
--env HERMES_HOME=${containerDataDir}/.hermes \
--env HERMES_MANAGED=true \
--env HOME=${containerHomeDir} \
--env MESSAGING_CWD=${containerWorkDir} \
${lib.concatStringsSep " " cfg.container.extraOptions} \
${cfg.container.image} \
${containerDataDir}/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;
};
};
})
]);
};
}

54
nix/packages.nix Normal file
View File

@@ -0,0 +1,54 @@
# nix/packages.nix — Hermes Agent package built with uv2nix
{ inputs, ... }: {
perSystem = { pkgs, system, ... }:
let
hermesVenv = pkgs.callPackage ./python.nix {
inherit (inputs) uv2nix pyproject-nix pyproject-build-systems;
};
# Import bundled skills, excluding runtime caches
bundledSkills = pkgs.lib.cleanSourceWith {
src = ../skills;
filter = path: _type:
!(pkgs.lib.hasInfix "/index-cache/" path);
};
runtimeDeps = with pkgs; [
nodejs_20 ripgrep git openssh ffmpeg
];
runtimePath = pkgs.lib.makeBinPath runtimeDeps;
in {
packages.default = pkgs.stdenv.mkDerivation {
pname = "hermes-agent";
version = "0.1.0";
dontUnpack = true;
dontBuild = true;
nativeBuildInputs = [ pkgs.makeWrapper ];
installPhase = ''
runHook preInstall
mkdir -p $out/share/hermes-agent $out/bin
cp -r ${bundledSkills} $out/share/hermes-agent/skills
${pkgs.lib.concatMapStringsSep "\n" (name: ''
makeWrapper ${hermesVenv}/bin/${name} $out/bin/${name} \
--prefix PATH : "${runtimePath}" \
--set HERMES_BUNDLED_SKILLS $out/share/hermes-agent/skills
'') [ "hermes" "hermes-agent" "hermes-acp" ]}
runHook postInstall
'';
meta = with pkgs.lib; {
description = "AI agent with advanced tool-calling capabilities";
homepage = "https://github.com/NousResearch/hermes-agent";
mainProgram = "hermes";
license = licenses.mit;
platforms = platforms.unix;
};
};
};
}

28
nix/python.nix Normal file
View File

@@ -0,0 +1,28 @@
# nix/python.nix — uv2nix virtual environment builder
{
python311,
lib,
callPackage,
uv2nix,
pyproject-nix,
pyproject-build-systems,
}:
let
workspace = uv2nix.lib.workspace.loadWorkspace { workspaceRoot = ./..; };
overlay = workspace.mkPyprojectOverlay {
sourcePreference = "wheel";
};
pythonSet =
(callPackage pyproject-nix.build.packages {
python = python311;
}).overrideScope
(lib.composeManyExtensions [
pyproject-build-systems.overlays.default
overlay
]);
in
pythonSet.mkVirtualEnv "hermes-agent-env" {
hermes-agent = [ "all" ];
}

View File

@@ -119,6 +119,70 @@ MIGRATION_OPTION_METADATA: Dict[str, Dict[str, str]] = {
"label": "Archive unmapped docs",
"description": "Archive compatible-but-unmapped docs for later manual review.",
},
"mcp-servers": {
"label": "MCP servers",
"description": "Import MCP server definitions from OpenClaw into Hermes config.yaml.",
},
"plugins-config": {
"label": "Plugins configuration",
"description": "Archive OpenClaw plugin configuration and installed extensions for manual review.",
},
"cron-jobs": {
"label": "Cron / scheduled tasks",
"description": "Import cron job definitions. Archive for manual recreation via 'hermes cron'.",
},
"hooks-config": {
"label": "Hooks and webhooks",
"description": "Archive OpenClaw hook configuration (internal hooks, webhooks, Gmail integration).",
},
"agent-config": {
"label": "Agent defaults and multi-agent setup",
"description": "Import agent defaults (compaction, context, thinking) into Hermes config. Archive multi-agent list.",
},
"gateway-config": {
"label": "Gateway configuration",
"description": "Import gateway port and auth settings. Archive full gateway config for manual setup.",
},
"session-config": {
"label": "Session configuration",
"description": "Import session reset policies (daily/idle) into Hermes session_reset config.",
},
"full-providers": {
"label": "Full model provider definitions",
"description": "Import custom model providers (baseUrl, apiType, headers) into Hermes custom_providers.",
},
"deep-channels": {
"label": "Deep channel configuration",
"description": "Import extended channel settings (Matrix, Mattermost, IRC, group configs). Archive complex settings.",
},
"browser-config": {
"label": "Browser configuration",
"description": "Import browser automation settings into Hermes config.yaml.",
},
"tools-config": {
"label": "Tools configuration",
"description": "Import tool settings (exec timeout, sandbox, web search) into Hermes config.yaml.",
},
"approvals-config": {
"label": "Approval rules",
"description": "Import approval mode and rules into Hermes config.yaml approvals section.",
},
"memory-backend": {
"label": "Memory backend configuration",
"description": "Archive OpenClaw memory backend settings (QMD, vector search, citations) for manual review.",
},
"skills-config": {
"label": "Skills registry configuration",
"description": "Archive per-skill enabled/config/env settings from OpenClaw skills.entries.",
},
"ui-identity": {
"label": "UI and identity settings",
"description": "Archive OpenClaw UI theme, assistant identity, and display preferences.",
},
"logging-config": {
"label": "Logging and diagnostics",
"description": "Archive OpenClaw logging and diagnostics configuration.",
},
}
MIGRATION_PRESETS: Dict[str, set[str]] = {
"user-data": {
@@ -139,6 +203,22 @@ MIGRATION_PRESETS: Dict[str, set[str]] = {
"shared-skills",
"daily-memory",
"archive",
"mcp-servers",
"agent-config",
"session-config",
"browser-config",
"tools-config",
"approvals-config",
"deep-channels",
"full-providers",
"plugins-config",
"cron-jobs",
"hooks-config",
"memory-backend",
"skills-config",
"ui-identity",
"logging-config",
"gateway-config",
},
"full": set(MIGRATION_OPTION_METADATA),
}
@@ -578,6 +658,28 @@ class Migrator:
),
)
self.run_if_selected("archive", self.archive_docs)
# ── v2 migration modules ──────────────────────────────
self.run_if_selected("mcp-servers", lambda: self.migrate_mcp_servers(config))
self.run_if_selected("plugins-config", lambda: self.migrate_plugins_config(config))
self.run_if_selected("cron-jobs", lambda: self.migrate_cron_jobs(config))
self.run_if_selected("hooks-config", lambda: self.migrate_hooks_config(config))
self.run_if_selected("agent-config", lambda: self.migrate_agent_config(config))
self.run_if_selected("gateway-config", lambda: self.migrate_gateway_config(config))
self.run_if_selected("session-config", lambda: self.migrate_session_config(config))
self.run_if_selected("full-providers", lambda: self.migrate_full_providers(config))
self.run_if_selected("deep-channels", lambda: self.migrate_deep_channels(config))
self.run_if_selected("browser-config", lambda: self.migrate_browser_config(config))
self.run_if_selected("tools-config", lambda: self.migrate_tools_config(config))
self.run_if_selected("approvals-config", lambda: self.migrate_approvals_config(config))
self.run_if_selected("memory-backend", lambda: self.migrate_memory_backend(config))
self.run_if_selected("skills-config", lambda: self.migrate_skills_config(config))
self.run_if_selected("ui-identity", lambda: self.migrate_ui_identity(config))
self.run_if_selected("logging-config", lambda: self.migrate_logging_config(config))
# Generate migration notes
self.generate_migration_notes()
return self.build_report()
def run_if_selected(self, option_id: str, func) -> None:
@@ -1459,6 +1561,776 @@ class Migrator:
else:
self.record("archive", source, destination, "archived", reason)
# ── MCP servers ─────────────────────────────────────────────
def migrate_mcp_servers(self, config: Optional[Dict[str, Any]] = None) -> None:
config = config or self.load_openclaw_config()
mcp_raw = (config.get("mcp") or {}).get("servers") or {}
if not mcp_raw:
self.record("mcp-servers", None, None, "skipped", "No MCP servers found in OpenClaw config")
return
hermes_cfg_path = self.target_root / "config.yaml"
hermes_cfg = load_yaml_file(hermes_cfg_path)
existing_mcp = hermes_cfg.get("mcp_servers") or {}
added = 0
for name, srv in mcp_raw.items():
if not isinstance(srv, dict):
continue
if name in existing_mcp and not self.overwrite:
self.record("mcp-servers", f"mcp.servers.{name}", f"mcp_servers.{name}", "conflict",
"MCP server already exists in Hermes config")
continue
hermes_srv: Dict[str, Any] = {}
# STDIO transport
if srv.get("command"):
hermes_srv["command"] = srv["command"]
if srv.get("args"):
hermes_srv["args"] = srv["args"]
if srv.get("env"):
hermes_srv["env"] = srv["env"]
if srv.get("cwd"):
hermes_srv["cwd"] = srv["cwd"]
# HTTP/SSE transport
if srv.get("url"):
hermes_srv["url"] = srv["url"]
if srv.get("headers"):
hermes_srv["headers"] = srv["headers"]
if srv.get("auth"):
hermes_srv["auth"] = srv["auth"]
# Common fields
if srv.get("enabled") is False:
hermes_srv["enabled"] = False
if srv.get("timeout"):
hermes_srv["timeout"] = srv["timeout"]
if srv.get("connectTimeout"):
hermes_srv["connect_timeout"] = srv["connectTimeout"]
# Tool filtering
tools_cfg = srv.get("tools") or {}
if tools_cfg.get("include") or tools_cfg.get("exclude"):
hermes_srv["tools"] = {}
if tools_cfg.get("include"):
hermes_srv["tools"]["include"] = tools_cfg["include"]
if tools_cfg.get("exclude"):
hermes_srv["tools"]["exclude"] = tools_cfg["exclude"]
# Sampling
sampling = srv.get("sampling")
if sampling and isinstance(sampling, dict):
hermes_srv["sampling"] = {
k: v for k, v in {
"enabled": sampling.get("enabled"),
"model": sampling.get("model"),
"max_tokens_cap": sampling.get("maxTokensCap") or sampling.get("max_tokens_cap"),
"timeout": sampling.get("timeout"),
"max_rpm": sampling.get("maxRpm") or sampling.get("max_rpm"),
}.items() if v is not None
}
existing_mcp[name] = hermes_srv
added += 1
self.record("mcp-servers", f"mcp.servers.{name}", f"config.yaml mcp_servers.{name}",
"migrated", servers_added=added)
if added > 0 and self.execute:
self.maybe_backup(hermes_cfg_path)
hermes_cfg["mcp_servers"] = existing_mcp
dump_yaml_file(hermes_cfg_path, hermes_cfg)
# ── Plugins ───────────────────────────────────────────────
def migrate_plugins_config(self, config: Optional[Dict[str, Any]] = None) -> None:
config = config or self.load_openclaw_config()
plugins = config.get("plugins") or {}
if not plugins:
self.record("plugins-config", None, None, "skipped", "No plugins configuration found")
return
# Archive the full plugins config
if self.archive_dir and self.execute:
self.archive_dir.mkdir(parents=True, exist_ok=True)
dest = self.archive_dir / "plugins-config.json"
dest.write_text(json.dumps(plugins, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
self.record("plugins-config", "openclaw.json plugins.*", str(dest), "archived",
"Plugins config archived for manual review")
else:
self.record("plugins-config", "openclaw.json plugins.*", "archive/plugins-config.json",
"archived" if not self.execute else "migrated", "Would archive plugins config")
# Copy extensions directory if it exists
ext_dir = self.source_root / "extensions"
if ext_dir.is_dir() and self.archive_dir:
dest_ext = self.archive_dir / "extensions"
if self.execute:
shutil.copytree(ext_dir, dest_ext, dirs_exist_ok=True)
self.record("plugins-config", str(ext_dir), str(dest_ext), "archived",
"Extensions directory archived")
# Extract any plugin env vars
entries = plugins.get("entries") or {}
for plugin_name, plugin_cfg in entries.items():
if isinstance(plugin_cfg, dict):
env_vars = plugin_cfg.get("env") or {}
api_key = plugin_cfg.get("apiKey")
if api_key and self.migrate_secrets:
env_key = f"PLUGIN_{plugin_name.upper().replace('-', '_')}_API_KEY"
self._set_env_var(env_key, api_key, f"plugins.entries.{plugin_name}.apiKey")
# ── Cron jobs ─────────────────────────────────────────────
def migrate_cron_jobs(self, config: Optional[Dict[str, Any]] = None) -> None:
config = config or self.load_openclaw_config()
cron = config.get("cron") or {}
if not cron:
self.record("cron-jobs", None, None, "skipped", "No cron configuration found")
return
# Archive the full cron config
if self.archive_dir and self.execute:
self.archive_dir.mkdir(parents=True, exist_ok=True)
dest = self.archive_dir / "cron-config.json"
dest.write_text(json.dumps(cron, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
self.record("cron-jobs", "openclaw.json cron.*", str(dest), "archived",
"Cron config archived. Use 'hermes cron' to recreate jobs manually.")
else:
self.record("cron-jobs", "openclaw.json cron.*", "archive/cron-config.json",
"archived", "Would archive cron config")
# Also check for cron store files
cron_store = self.source_root / "cron"
if cron_store.is_dir() and self.archive_dir:
dest_cron = self.archive_dir / "cron-store"
if self.execute:
shutil.copytree(cron_store, dest_cron, dirs_exist_ok=True)
self.record("cron-jobs", str(cron_store), str(dest_cron), "archived",
"Cron job store archived")
# ── Hooks ─────────────────────────────────────────────────
def migrate_hooks_config(self, config: Optional[Dict[str, Any]] = None) -> None:
config = config or self.load_openclaw_config()
hooks = config.get("hooks") or {}
if not hooks:
self.record("hooks-config", None, None, "skipped", "No hooks configuration found")
return
# Archive the full hooks config
if self.archive_dir and self.execute:
self.archive_dir.mkdir(parents=True, exist_ok=True)
dest = self.archive_dir / "hooks-config.json"
dest.write_text(json.dumps(hooks, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
self.record("hooks-config", "openclaw.json hooks.*", str(dest), "archived",
"Hooks config archived for manual review")
else:
self.record("hooks-config", "openclaw.json hooks.*", "archive/hooks-config.json",
"archived", "Would archive hooks config")
# Copy workspace hooks directory
for ws_name in ("workspace", "workspace.default"):
hooks_dir = self.source_root / ws_name / "hooks"
if hooks_dir.is_dir() and self.archive_dir:
dest_hooks = self.archive_dir / "workspace-hooks"
if self.execute:
shutil.copytree(hooks_dir, dest_hooks, dirs_exist_ok=True)
self.record("hooks-config", str(hooks_dir), str(dest_hooks), "archived",
"Workspace hooks directory archived")
break
# ── Agent config ──────────────────────────────────────────
def migrate_agent_config(self, config: Optional[Dict[str, Any]] = None) -> None:
config = config or self.load_openclaw_config()
agents = config.get("agents") or {}
defaults = agents.get("defaults") or {}
agent_list = agents.get("list") or []
if not defaults and not agent_list:
self.record("agent-config", None, None, "skipped", "No agent configuration found")
return
hermes_cfg_path = self.target_root / "config.yaml"
hermes_cfg = load_yaml_file(hermes_cfg_path)
changes = False
# Map agent defaults
agent_cfg = hermes_cfg.get("agent") or {}
if defaults.get("contextTokens"):
# No direct mapping but useful context
pass
if defaults.get("timeoutSeconds"):
agent_cfg["max_turns"] = min(defaults["timeoutSeconds"] // 10, 200)
changes = True
if defaults.get("verboseDefault"):
agent_cfg["verbose"] = defaults["verboseDefault"]
changes = True
if defaults.get("thinkingDefault"):
# Map OpenClaw thinking -> Hermes reasoning_effort
thinking = defaults["thinkingDefault"]
if thinking in ("always", "high"):
agent_cfg["reasoning_effort"] = "high"
elif thinking in ("auto", "medium"):
agent_cfg["reasoning_effort"] = "medium"
elif thinking in ("off", "low", "none"):
agent_cfg["reasoning_effort"] = "low"
changes = True
# Map compaction -> compression
compaction = defaults.get("compaction") or {}
if compaction:
compression = hermes_cfg.get("compression") or {}
if compaction.get("mode") == "off":
compression["enabled"] = False
else:
compression["enabled"] = True
if compaction.get("timeout"):
pass # No direct mapping
if compaction.get("model"):
compression["summary_model"] = compaction["model"]
hermes_cfg["compression"] = compression
changes = True
# Map humanDelay
human_delay = defaults.get("humanDelay") or {}
if human_delay:
hd = hermes_cfg.get("human_delay") or {}
if human_delay.get("enabled"):
hd["mode"] = "natural"
if human_delay.get("minMs"):
hd["min_ms"] = human_delay["minMs"]
if human_delay.get("maxMs"):
hd["max_ms"] = human_delay["maxMs"]
hermes_cfg["human_delay"] = hd
changes = True
# Map userTimezone
if defaults.get("userTimezone"):
hermes_cfg["timezone"] = defaults["userTimezone"]
changes = True
# Map terminal/exec settings
exec_cfg = defaults.get("exec") or (config.get("tools") or {}).get("exec") or {}
if exec_cfg:
terminal_cfg = hermes_cfg.get("terminal") or {}
if exec_cfg.get("timeout"):
terminal_cfg["timeout"] = exec_cfg["timeout"]
changes = True
hermes_cfg["terminal"] = terminal_cfg
# Map sandbox -> terminal docker settings
sandbox = defaults.get("sandbox") or {}
if sandbox and sandbox.get("backend") == "docker":
terminal_cfg = hermes_cfg.get("terminal") or {}
terminal_cfg["backend"] = "docker"
if sandbox.get("docker", {}).get("image"):
terminal_cfg["docker_image"] = sandbox["docker"]["image"]
hermes_cfg["terminal"] = terminal_cfg
changes = True
if changes:
hermes_cfg["agent"] = agent_cfg
if self.execute:
self.maybe_backup(hermes_cfg_path)
dump_yaml_file(hermes_cfg_path, hermes_cfg)
self.record("agent-config", "openclaw.json agents.defaults", "config.yaml agent/compression/terminal",
"migrated", "Agent defaults mapped to Hermes config")
# Archive multi-agent list
if agent_list:
if self.archive_dir and self.execute:
self.archive_dir.mkdir(parents=True, exist_ok=True)
dest = self.archive_dir / "agents-list.json"
dest.write_text(json.dumps(agent_list, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
self.record("agent-config", "openclaw.json agents.list", "archive/agents-list.json",
"archived", f"Multi-agent setup ({len(agent_list)} agents) archived for manual recreation")
# Archive bindings
bindings = config.get("bindings") or []
if bindings:
if self.archive_dir and self.execute:
self.archive_dir.mkdir(parents=True, exist_ok=True)
dest = self.archive_dir / "bindings.json"
dest.write_text(json.dumps(bindings, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
self.record("agent-config", "openclaw.json bindings", "archive/bindings.json",
"archived", f"Agent routing bindings ({len(bindings)} rules) archived")
# ── Gateway config ────────────────────────────────────────
def migrate_gateway_config(self, config: Optional[Dict[str, Any]] = None) -> None:
config = config or self.load_openclaw_config()
gateway = config.get("gateway") or {}
if not gateway:
self.record("gateway-config", None, None, "skipped", "No gateway configuration found")
return
# Archive the full gateway config (complex, many settings)
if self.archive_dir and self.execute:
self.archive_dir.mkdir(parents=True, exist_ok=True)
dest = self.archive_dir / "gateway-config.json"
dest.write_text(json.dumps(gateway, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
self.record("gateway-config", "openclaw.json gateway.*", "archive/gateway-config.json",
"archived", "Gateway config archived. Use 'hermes gateway' to configure.")
# Extract gateway auth token to .env if present
auth = gateway.get("auth") or {}
if auth.get("token") and self.migrate_secrets:
self._set_env_var("HERMES_GATEWAY_TOKEN", auth["token"], "gateway.auth.token")
# ── Session config ────────────────────────────────────────
def migrate_session_config(self, config: Optional[Dict[str, Any]] = None) -> None:
config = config or self.load_openclaw_config()
session = config.get("session") or {}
if not session:
self.record("session-config", None, None, "skipped", "No session configuration found")
return
hermes_cfg_path = self.target_root / "config.yaml"
hermes_cfg = load_yaml_file(hermes_cfg_path)
sr = hermes_cfg.get("session_reset") or {}
changes = False
reset_triggers = session.get("resetTriggers") or session.get("reset_triggers") or {}
if reset_triggers:
daily = reset_triggers.get("daily") or {}
idle = reset_triggers.get("idle") or {}
if daily.get("enabled") and idle.get("enabled"):
sr["mode"] = "both"
elif daily.get("enabled"):
sr["mode"] = "daily"
elif idle.get("enabled"):
sr["mode"] = "idle"
else:
sr["mode"] = "none"
if daily.get("hour") is not None:
sr["at_hour"] = daily["hour"]
if idle.get("minutes") or idle.get("timeoutMinutes"):
sr["idle_minutes"] = idle.get("minutes") or idle.get("timeoutMinutes")
changes = True
if changes:
hermes_cfg["session_reset"] = sr
if self.execute:
self.maybe_backup(hermes_cfg_path)
dump_yaml_file(hermes_cfg_path, hermes_cfg)
self.record("session-config", "openclaw.json session.resetTriggers",
"config.yaml session_reset", "migrated")
# Archive full session config (identity links, thread bindings, etc.)
complex_keys = {"identityLinks", "threadBindings", "maintenance", "scope", "sendPolicy"}
complex_session = {k: v for k, v in session.items() if k in complex_keys and v}
if complex_session and self.archive_dir:
if self.execute:
self.archive_dir.mkdir(parents=True, exist_ok=True)
dest = self.archive_dir / "session-config.json"
dest.write_text(json.dumps(complex_session, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
self.record("session-config", "openclaw.json session (advanced)",
"archive/session-config.json", "archived",
"Advanced session settings archived (identity links, thread bindings, etc.)")
# ── Full model providers ──────────────────────────────────
def migrate_full_providers(self, config: Optional[Dict[str, Any]] = None) -> None:
config = config or self.load_openclaw_config()
models = config.get("models") or {}
providers = models.get("providers") or {}
if not providers:
self.record("full-providers", None, None, "skipped", "No model providers found")
return
hermes_cfg_path = self.target_root / "config.yaml"
hermes_cfg = load_yaml_file(hermes_cfg_path)
custom_providers = hermes_cfg.get("custom_providers") or []
added = 0
# Well-known providers: just extract API keys
WELL_KNOWN = {"openrouter", "openai", "anthropic", "deepseek", "google", "groq"}
for prov_name, prov_cfg in providers.items():
if not isinstance(prov_cfg, dict):
continue
# Extract API key to .env
api_key = prov_cfg.get("apiKey") or prov_cfg.get("api_key")
if api_key and self.migrate_secrets:
env_key = f"{prov_name.upper().replace('-', '_')}_API_KEY"
self._set_env_var(env_key, api_key, f"models.providers.{prov_name}.apiKey")
# For non-well-known providers, create custom_providers entry
if prov_name.lower() not in WELL_KNOWN and prov_cfg.get("baseUrl"):
# Check if already exists
existing_names = {p.get("name", "").lower() for p in custom_providers}
if prov_name.lower() in existing_names and not self.overwrite:
self.record("full-providers", f"models.providers.{prov_name}",
"config.yaml custom_providers", "conflict",
f"Provider '{prov_name}' already exists")
continue
api_type = prov_cfg.get("apiType") or prov_cfg.get("type") or "openai"
api_mode_map = {
"openai": "chat_completions",
"anthropic": "anthropic_messages",
"cohere": "chat_completions",
}
entry = {
"name": prov_name,
"base_url": prov_cfg["baseUrl"],
"api_key": "", # referenced from .env
"api_mode": api_mode_map.get(api_type, "chat_completions"),
}
custom_providers.append(entry)
added += 1
self.record("full-providers", f"models.providers.{prov_name}",
f"config.yaml custom_providers[{prov_name}]", "migrated")
if added > 0 and self.execute:
self.maybe_backup(hermes_cfg_path)
hermes_cfg["custom_providers"] = custom_providers
dump_yaml_file(hermes_cfg_path, hermes_cfg)
# Archive model aliases/catalog
agent_defaults = (config.get("agents") or {}).get("defaults") or {}
model_aliases = agent_defaults.get("models") or {}
if model_aliases:
if self.archive_dir and self.execute:
self.archive_dir.mkdir(parents=True, exist_ok=True)
dest = self.archive_dir / "model-aliases.json"
dest.write_text(json.dumps(model_aliases, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
self.record("full-providers", "agents.defaults.models", "archive/model-aliases.json",
"archived", f"Model aliases/catalog ({len(model_aliases)} entries) archived")
# ── Deep channel config ───────────────────────────────────
def migrate_deep_channels(self, config: Optional[Dict[str, Any]] = None) -> None:
config = config or self.load_openclaw_config()
channels = config.get("channels") or {}
if not channels:
self.record("deep-channels", None, None, "skipped", "No channel configuration found")
return
# Extended channel token/allowlist mapping
CHANNEL_ENV_MAP = {
"matrix": {"token": "MATRIX_ACCESS_TOKEN", "allowFrom": "MATRIX_ALLOWED_USERS",
"extras": {"homeserverUrl": "MATRIX_HOMESERVER_URL", "userId": "MATRIX_USER_ID"}},
"mattermost": {"token": "MATTERMOST_BOT_TOKEN", "allowFrom": "MATTERMOST_ALLOWED_USERS",
"extras": {"url": "MATTERMOST_URL", "teamId": "MATTERMOST_TEAM_ID"}},
"irc": {"extras": {"server": "IRC_SERVER", "nick": "IRC_NICK", "channels": "IRC_CHANNELS"}},
"googlechat": {"extras": {"serviceAccountKeyPath": "GOOGLE_CHAT_SA_KEY_PATH"}},
"imessage": {},
"bluebubbles": {"extras": {"server": "BLUEBUBBLES_SERVER", "password": "BLUEBUBBLES_PASSWORD"}},
"msteams": {"token": "MSTEAMS_BOT_TOKEN", "allowFrom": "MSTEAMS_ALLOWED_USERS"},
"nostr": {"extras": {"nsec": "NOSTR_NSEC", "relays": "NOSTR_RELAYS"}},
"twitch": {"token": "TWITCH_BOT_TOKEN", "extras": {"channels": "TWITCH_CHANNELS"}},
}
for ch_name, ch_mapping in CHANNEL_ENV_MAP.items():
ch_cfg = channels.get(ch_name) or {}
if not ch_cfg:
continue
# Extract tokens
if ch_mapping.get("token") and ch_cfg.get("botToken") and self.migrate_secrets:
self._set_env_var(ch_mapping["token"], ch_cfg["botToken"],
f"channels.{ch_name}.botToken")
if ch_mapping.get("allowFrom") and ch_cfg.get("allowFrom"):
allow_val = ch_cfg["allowFrom"]
if isinstance(allow_val, list):
allow_val = ",".join(str(x) for x in allow_val)
self._set_env_var(ch_mapping["allowFrom"], str(allow_val),
f"channels.{ch_name}.allowFrom")
# Extra fields
for oc_key, env_key in (ch_mapping.get("extras") or {}).items():
val = ch_cfg.get(oc_key)
if val:
if isinstance(val, list):
val = ",".join(str(x) for x in val)
is_secret = "password" in oc_key.lower() or "token" in oc_key.lower() or "nsec" in oc_key.lower()
if is_secret and not self.migrate_secrets:
continue
self._set_env_var(env_key, str(val), f"channels.{ch_name}.{oc_key}")
# Map Discord-specific settings to Hermes config
discord_cfg = channels.get("discord") or {}
if discord_cfg:
hermes_cfg_path = self.target_root / "config.yaml"
hermes_cfg = load_yaml_file(hermes_cfg_path)
discord_hermes = hermes_cfg.get("discord") or {}
changed = False
if "requireMention" in discord_cfg:
discord_hermes["require_mention"] = discord_cfg["requireMention"]
changed = True
if discord_cfg.get("autoThread") is not None:
discord_hermes["auto_thread"] = discord_cfg["autoThread"]
changed = True
if changed and self.execute:
hermes_cfg["discord"] = discord_hermes
dump_yaml_file(hermes_cfg_path, hermes_cfg)
# Archive complex channel configs (group settings, thread bindings, etc.)
complex_archive = {}
for ch_name, ch_cfg in channels.items():
if not isinstance(ch_cfg, dict):
continue
complex_keys = {k: v for k, v in ch_cfg.items()
if k not in ("botToken", "appToken", "allowFrom", "enabled")
and v and k not in ("requireMention", "autoThread")}
if complex_keys:
complex_archive[ch_name] = complex_keys
if complex_archive and self.archive_dir:
if self.execute:
self.archive_dir.mkdir(parents=True, exist_ok=True)
dest = self.archive_dir / "channels-deep-config.json"
dest.write_text(json.dumps(complex_archive, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
self.record("deep-channels", "openclaw.json channels (advanced settings)",
"archive/channels-deep-config.json", "archived",
f"Deep channel config for {len(complex_archive)} channels archived")
# ── Browser config ────────────────────────────────────────
def migrate_browser_config(self, config: Optional[Dict[str, Any]] = None) -> None:
config = config or self.load_openclaw_config()
browser = config.get("browser") or {}
if not browser:
self.record("browser-config", None, None, "skipped", "No browser configuration found")
return
hermes_cfg_path = self.target_root / "config.yaml"
hermes_cfg = load_yaml_file(hermes_cfg_path)
browser_hermes = hermes_cfg.get("browser") or {}
changed = False
if browser.get("inactivityTimeoutMs"):
browser_hermes["inactivity_timeout"] = browser["inactivityTimeoutMs"] // 1000
changed = True
if browser.get("commandTimeoutMs"):
browser_hermes["command_timeout"] = browser["commandTimeoutMs"] // 1000
changed = True
if changed:
hermes_cfg["browser"] = browser_hermes
if self.execute:
self.maybe_backup(hermes_cfg_path)
dump_yaml_file(hermes_cfg_path, hermes_cfg)
self.record("browser-config", "openclaw.json browser.*", "config.yaml browser",
"migrated")
# Archive advanced browser settings
advanced = {k: v for k, v in browser.items()
if k not in ("inactivityTimeoutMs", "commandTimeoutMs") and v}
if advanced and self.archive_dir:
if self.execute:
self.archive_dir.mkdir(parents=True, exist_ok=True)
dest = self.archive_dir / "browser-config.json"
dest.write_text(json.dumps(advanced, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
self.record("browser-config", "openclaw.json browser (advanced)",
"archive/browser-config.json", "archived")
# ── Tools config ──────────────────────────────────────────
def migrate_tools_config(self, config: Optional[Dict[str, Any]] = None) -> None:
config = config or self.load_openclaw_config()
tools = config.get("tools") or {}
if not tools:
self.record("tools-config", None, None, "skipped", "No tools configuration found")
return
hermes_cfg_path = self.target_root / "config.yaml"
hermes_cfg = load_yaml_file(hermes_cfg_path)
changed = False
# Map exec timeout -> terminal timeout
exec_cfg = tools.get("exec") or {}
if exec_cfg.get("timeout"):
terminal_cfg = hermes_cfg.get("terminal") or {}
terminal_cfg["timeout"] = exec_cfg["timeout"]
hermes_cfg["terminal"] = terminal_cfg
changed = True
# Map web search API key
web_cfg = tools.get("webSearch") or tools.get("web") or {}
if web_cfg.get("braveApiKey") and self.migrate_secrets:
self._set_env_var("BRAVE_API_KEY", web_cfg["braveApiKey"], "tools.webSearch.braveApiKey")
if changed and self.execute:
self.maybe_backup(hermes_cfg_path)
dump_yaml_file(hermes_cfg_path, hermes_cfg)
self.record("tools-config", "openclaw.json tools.*", "config.yaml terminal",
"migrated")
# Archive full tools config
if self.archive_dir:
if self.execute:
self.archive_dir.mkdir(parents=True, exist_ok=True)
dest = self.archive_dir / "tools-config.json"
dest.write_text(json.dumps(tools, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
self.record("tools-config", "openclaw.json tools (full)", "archive/tools-config.json",
"archived", "Full tools config archived for reference")
# ── Approvals config ──────────────────────────────────────
def migrate_approvals_config(self, config: Optional[Dict[str, Any]] = None) -> None:
config = config or self.load_openclaw_config()
approvals = config.get("approvals") or {}
if not approvals:
self.record("approvals-config", None, None, "skipped", "No approvals configuration found")
return
hermes_cfg_path = self.target_root / "config.yaml"
hermes_cfg = load_yaml_file(hermes_cfg_path)
# Map approval mode
mode = approvals.get("mode") or approvals.get("defaultMode")
if mode:
mode_map = {"auto": "off", "always": "manual", "smart": "smart", "manual": "manual"}
hermes_mode = mode_map.get(mode, "manual")
hermes_cfg.setdefault("approvals", {})["mode"] = hermes_mode
if self.execute:
self.maybe_backup(hermes_cfg_path)
dump_yaml_file(hermes_cfg_path, hermes_cfg)
self.record("approvals-config", "openclaw.json approvals.mode",
"config.yaml approvals.mode", "migrated", f"Mapped '{mode}' -> '{hermes_mode}'")
# Archive full approvals config
if len(approvals) > 1 and self.archive_dir:
if self.execute:
self.archive_dir.mkdir(parents=True, exist_ok=True)
dest = self.archive_dir / "approvals-config.json"
dest.write_text(json.dumps(approvals, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
self.record("approvals-config", "openclaw.json approvals (rules)",
"archive/approvals-config.json", "archived")
# ── Memory backend ────────────────────────────────────────
def migrate_memory_backend(self, config: Optional[Dict[str, Any]] = None) -> None:
config = config or self.load_openclaw_config()
memory = config.get("memory") or {}
if not memory:
self.record("memory-backend", None, None, "skipped", "No memory backend configuration found")
return
if self.archive_dir and self.execute:
self.archive_dir.mkdir(parents=True, exist_ok=True)
dest = self.archive_dir / "memory-backend-config.json"
dest.write_text(json.dumps(memory, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
self.record("memory-backend", "openclaw.json memory.*", "archive/memory-backend-config.json",
"archived", "Memory backend config (QMD, vector search, citations) archived for manual review")
# ── Skills config ─────────────────────────────────────────
def migrate_skills_config(self, config: Optional[Dict[str, Any]] = None) -> None:
config = config or self.load_openclaw_config()
skills = config.get("skills") or {}
entries = skills.get("entries") or {}
if not entries and not skills:
self.record("skills-config", None, None, "skipped", "No skills registry configuration found")
return
if self.archive_dir and self.execute:
self.archive_dir.mkdir(parents=True, exist_ok=True)
dest = self.archive_dir / "skills-registry-config.json"
dest.write_text(json.dumps(skills, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
self.record("skills-config", "openclaw.json skills.*", "archive/skills-registry-config.json",
"archived", f"Skills registry config ({len(entries)} entries) archived")
# ── UI / Identity ─────────────────────────────────────────
def migrate_ui_identity(self, config: Optional[Dict[str, Any]] = None) -> None:
config = config or self.load_openclaw_config()
ui = config.get("ui") or {}
if not ui:
self.record("ui-identity", None, None, "skipped", "No UI/identity configuration found")
return
if self.archive_dir and self.execute:
self.archive_dir.mkdir(parents=True, exist_ok=True)
dest = self.archive_dir / "ui-identity-config.json"
dest.write_text(json.dumps(ui, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
self.record("ui-identity", "openclaw.json ui.*", "archive/ui-identity-config.json",
"archived", "UI theme and identity settings archived")
# ── Logging / Diagnostics ─────────────────────────────────
def migrate_logging_config(self, config: Optional[Dict[str, Any]] = None) -> None:
config = config or self.load_openclaw_config()
logging_cfg = config.get("logging") or {}
diagnostics = config.get("diagnostics") or {}
combined = {}
if logging_cfg:
combined["logging"] = logging_cfg
if diagnostics:
combined["diagnostics"] = diagnostics
if not combined:
self.record("logging-config", None, None, "skipped", "No logging/diagnostics configuration found")
return
if self.archive_dir and self.execute:
self.archive_dir.mkdir(parents=True, exist_ok=True)
dest = self.archive_dir / "logging-diagnostics-config.json"
dest.write_text(json.dumps(combined, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
self.record("logging-config", "openclaw.json logging/diagnostics",
"archive/logging-diagnostics-config.json", "archived")
# ── Helper: set env var ───────────────────────────────────
def _set_env_var(self, key: str, value: str, source_label: str) -> None:
env_path = self.target_root / ".env"
if self.execute:
env_data = parse_env_file(env_path)
if key in env_data and not self.overwrite:
self.record("env-var", source_label, f".env {key}", "conflict",
f"Env var {key} already set")
return
env_data[key] = value
save_env_file(env_path, env_data)
self.record("env-var", source_label, f".env {key}", "migrated")
# ── Generate migration notes ──────────────────────────────
def generate_migration_notes(self) -> None:
if not self.output_dir:
return
notes = [
"# OpenClaw -> Hermes Migration Notes",
"",
"This document lists items that require manual attention after migration.",
"",
"## PM2 / External Processes",
"",
"Your PM2 processes (Discord bots, Telegram bots, etc.) are NOT affected",
"by this migration. They run independently and will continue working.",
"No action needed for PM2-managed processes.",
"",
]
archived = [i for i in self.items if i.status == "archived"]
if archived:
notes.extend([
"## Archived Items (Manual Review Needed)",
"",
"These OpenClaw configurations were archived because they don't have a",
"direct 1:1 mapping in Hermes. Review each file and recreate manually:",
"",
])
for item in archived:
notes.append(f"- **{item.kind}**: `{item.destination}` -- {item.reason}")
notes.append("")
conflicts = [i for i in self.items if i.status == "conflict"]
if conflicts:
notes.extend([
"## Conflicts (Existing Hermes Config Not Overwritten)",
"",
"These items already existed in your Hermes config. Re-run with",
"`--overwrite` to force, or merge manually:",
"",
])
for item in conflicts:
notes.append(f"- **{item.kind}**: {item.reason}")
notes.append("")
notes.extend([
"## Hermes-Specific Setup",
"",
"After migration, you may want to:",
"- Run `hermes setup` to configure any remaining settings",
"- Run `hermes mcp list` to verify MCP servers were imported correctly",
"- Run `hermes cron` to recreate scheduled tasks (see archive/cron-config.json)",
"- Run `hermes gateway install` if you need the gateway service",
"- Review `~/.hermes/config.yaml` for any adjustments",
"",
])
if self.execute:
self.output_dir.mkdir(parents=True, exist_ok=True)
(self.output_dir / "MIGRATION_NOTES.md").write_text(
"\n".join(notes) + "\n", encoding="utf-8"
)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Migrate OpenClaw user state into Hermes Agent.")
@@ -1524,8 +2396,101 @@ def main() -> int:
skill_conflict_mode=args.skill_conflict,
)
report = migrator.migrate()
print(json.dumps(report, indent=2, ensure_ascii=False))
return 0 if report["summary"].get("error", 0) == 0 else 1
# ── Human-readable terminal recap ─────────────────────────
s = report["summary"]
items = report["items"]
mode_label = "DRY RUN" if not args.execute else "EXECUTED"
total = sum(s.values())
print()
print(f" ╔══════════════════════════════════════════════════════╗")
print(f" ║ OpenClaw -> Hermes Migration [{mode_label:>8s}] ║")
print(f" ╠══════════════════════════════════════════════════════╣")
print(f" ║ Source: {str(report['source_root'])[:42]:<42s}")
print(f" ║ Target: {str(report['target_root'])[:42]:<42s}")
print(f" ╠══════════════════════════════════════════════════════╣")
print(f" ║ ✔ Migrated: {s.get('migrated', 0):>3d} ◆ Archived: {s.get('archived', 0):>3d}")
print(f" ║ ⊘ Skipped: {s.get('skipped', 0):>3d} ⚠ Conflicts: {s.get('conflict', 0):>3d}")
print(f" ║ ✖ Errors: {s.get('error', 0):>3d} Total: {total:>3d}")
print(f" ╚══════════════════════════════════════════════════════╝")
# Show what was migrated
migrated = [i for i in items if i["status"] == "migrated"]
if migrated:
print()
print(" Migrated:")
seen_kinds = set()
for item in migrated:
label = item["kind"]
if label in seen_kinds:
continue
seen_kinds.add(label)
dest = item.get("destination") or ""
if dest.startswith(str(report["target_root"])):
dest = "~/.hermes/" + dest[len(str(report["target_root"])) + 1:]
meta = MIGRATION_OPTION_METADATA.get(label, {})
display = meta.get("label", label)
print(f"{display:<35s} -> {dest}")
# Show what was archived
archived = [i for i in items if i["status"] == "archived"]
if archived:
print()
print(" Archived (manual review needed):")
seen_kinds = set()
for item in archived:
label = item["kind"]
if label in seen_kinds:
continue
seen_kinds.add(label)
reason = item.get("reason", "")
meta = MIGRATION_OPTION_METADATA.get(label, {})
display = meta.get("label", label)
short_reason = reason[:50] + "..." if len(reason) > 50 else reason
print(f"{display:<35s} {short_reason}")
# Show conflicts
conflicts = [i for i in items if i["status"] == "conflict"]
if conflicts:
print()
print(" Conflicts (use --overwrite to force):")
for item in conflicts:
print(f"{item['kind']}: {item.get('reason', '')}")
# Show errors
errors = [i for i in items if i["status"] == "error"]
if errors:
print()
print(" Errors:")
for item in errors:
print(f"{item['kind']}: {item.get('reason', '')}")
# PM2 reassurance
print()
print(" PM2 processes (Discord/Telegram bots) are NOT affected.")
# Next steps
if args.execute:
print()
print(" Next steps:")
print(" 1. Review ~/.hermes/config.yaml")
print(" 2. Run: hermes mcp list")
if any(i["kind"] == "cron-jobs" and i["status"] == "archived" for i in items):
print(" 3. Recreate cron jobs: hermes cron")
if report.get("output_dir"):
print(f" → Full report: {report['output_dir']}/MIGRATION_NOTES.md")
elif not args.execute:
print()
print(" This was a dry run. Add --execute to apply changes.")
print()
# Also dump JSON for programmatic use
if os.environ.get("MIGRATION_JSON_OUTPUT"):
print(json.dumps(report, indent=2, ensure_ascii=False))
return 0 if s.get("error", 0) == 0 else 1
if __name__ == "__main__":

View File

@@ -3585,20 +3585,7 @@ class AIAgent:
def _call_chat_completions():
"""Stream a chat completions response."""
import httpx as _httpx
_base_timeout = float(os.getenv("HERMES_API_TIMEOUT", 900.0))
_stream_read_timeout = float(os.getenv("HERMES_STREAM_READ_TIMEOUT", 60.0))
stream_kwargs = {
**api_kwargs,
"stream": True,
"stream_options": {"include_usage": True},
"timeout": _httpx.Timeout(
connect=30.0,
read=_stream_read_timeout,
write=_base_timeout,
pool=30.0,
),
}
stream_kwargs = {**api_kwargs, "stream": True, "stream_options": {"include_usage": True}}
request_client_holder["client"] = self._create_request_openai_client(
reason="chat_completion_stream_request"
)
@@ -3666,7 +3653,6 @@ class AIAgent:
name = entry["function"]["name"]
if name and idx not in tool_gen_notified:
tool_gen_notified.add(idx)
_fire_first_delta()
self._fire_tool_gen_started(name)
if chunk.choices[0].finish_reason:
@@ -3735,7 +3721,6 @@ class AIAgent:
has_tool_use = True
tool_name = getattr(block, "name", None)
if tool_name:
_fire_first_delta()
self._fire_tool_gen_started(tool_name)
elif event_type == "content_block_delta":
@@ -3757,84 +3742,29 @@ class AIAgent:
return stream.get_final_message()
def _call():
import httpx as _httpx
_max_stream_retries = int(os.getenv("HERMES_STREAM_RETRIES", 2))
try:
for _stream_attempt in range(_max_stream_retries + 1):
if self.api_mode == "anthropic_messages":
self._try_refresh_anthropic_client_credentials()
result["response"] = _call_anthropic()
else:
result["response"] = _call_chat_completions()
except Exception as e:
if deltas_were_sent["yes"]:
# Streaming failed AFTER some tokens were already delivered
# to consumers. Don't fall back — that would cause
# double-delivery (partial streamed + full non-streamed).
# Let the error propagate; the partial content already
# reached the user via the stream.
logger.warning("Streaming failed after partial delivery, not falling back: %s", e)
result["error"] = e
else:
# Streaming failed before any tokens reached consumers.
# Safe to fall back to the standard non-streaming path.
logger.info("Streaming failed before delivery, falling back to non-streaming: %s", e)
try:
if self.api_mode == "anthropic_messages":
self._try_refresh_anthropic_client_credentials()
result["response"] = _call_anthropic()
else:
result["response"] = _call_chat_completions()
return # success
except Exception as e:
if deltas_were_sent["yes"]:
# Streaming failed AFTER some tokens were already
# delivered. Don't retry or fall back — partial
# content already reached the user.
logger.warning(
"Streaming failed after partial delivery, not retrying: %s", e
)
result["error"] = e
return
_is_timeout = isinstance(
e, (_httpx.ReadTimeout, _httpx.ConnectTimeout, _httpx.PoolTimeout)
)
_is_conn_err = isinstance(
e, (_httpx.ConnectError, _httpx.RemoteProtocolError, ConnectionError)
)
if _is_timeout or _is_conn_err:
# Transient network / timeout error. Retry the
# streaming request with a fresh connection rather
# than falling back to non-streaming (which would
# hang for up to 15 min on the same dead server).
if _stream_attempt < _max_stream_retries:
logger.info(
"Streaming attempt %s/%s failed (%s: %s), "
"retrying with fresh connection...",
_stream_attempt + 1,
_max_stream_retries + 1,
type(e).__name__,
e,
)
# Close the stale request client before retry
stale = request_client_holder.get("client")
if stale is not None:
self._close_request_openai_client(
stale, reason="stream_retry_cleanup"
)
request_client_holder["client"] = None
continue
# Exhausted retries — propagate to outer loop
logger.warning(
"Streaming exhausted %s retries on transient error: %s",
_max_stream_retries + 1,
e,
)
result["error"] = e
return
# Non-transient error (e.g. "streaming not supported",
# auth error, 4xx). Fall back to non-streaming once.
err_msg = str(e).lower()
if "stream" in err_msg and "not supported" in err_msg:
logger.info(
"Streaming not supported, falling back to non-streaming: %s", e
)
try:
result["response"] = self._interruptible_api_call(api_kwargs)
except Exception as fallback_err:
result["error"] = fallback_err
return
# Unknown error — propagate to outer retry loop
result["error"] = e
return
result["response"] = self._interruptible_api_call(api_kwargs)
except Exception as fallback_err:
result["error"] = fallback_err
finally:
request_client = request_client_holder.get("client")
if request_client is not None:

View File

@@ -0,0 +1,242 @@
"""Tests for Telegram reply_to_mode functionality.
Covers the threading behavior control for multi-chunk replies:
- "off": Never thread replies to original message
- "first": Only first chunk threads (default)
- "all": All chunks thread to original message
"""
import os
import sys
from unittest.mock import MagicMock, AsyncMock, patch
import pytest
from gateway.config import PlatformConfig, GatewayConfig, Platform, _apply_env_overrides
def _ensure_telegram_mock():
"""Mock the telegram package if it's not installed."""
if "telegram" in sys.modules and hasattr(sys.modules["telegram"], "__file__"):
return
mod = MagicMock()
mod.ext.ContextTypes.DEFAULT_TYPE = type(None)
mod.constants.ParseMode.MARKDOWN_V2 = "MarkdownV2"
mod.constants.ChatType.GROUP = "group"
mod.constants.ChatType.SUPERGROUP = "supergroup"
mod.constants.ChatType.CHANNEL = "channel"
mod.constants.ChatType.PRIVATE = "private"
for name in ("telegram", "telegram.ext", "telegram.constants"):
sys.modules.setdefault(name, mod)
_ensure_telegram_mock()
from gateway.platforms.telegram import TelegramAdapter # noqa: E402
@pytest.fixture()
def adapter_factory():
"""Factory to create TelegramAdapter with custom reply_to_mode."""
def create(reply_to_mode: str = "first"):
config = PlatformConfig(enabled=True, token="test-token", reply_to_mode=reply_to_mode)
return TelegramAdapter(config)
return create
class TestReplyToModeConfig:
"""Tests for reply_to_mode configuration loading."""
def test_default_mode_is_first(self, adapter_factory):
adapter = adapter_factory()
assert adapter._reply_to_mode == "first"
def test_off_mode(self, adapter_factory):
adapter = adapter_factory(reply_to_mode="off")
assert adapter._reply_to_mode == "off"
def test_first_mode(self, adapter_factory):
adapter = adapter_factory(reply_to_mode="first")
assert adapter._reply_to_mode == "first"
def test_all_mode(self, adapter_factory):
adapter = adapter_factory(reply_to_mode="all")
assert adapter._reply_to_mode == "all"
def test_invalid_mode_stored_as_is(self, adapter_factory):
"""Invalid modes are stored but _should_thread_reply handles them."""
adapter = adapter_factory(reply_to_mode="invalid")
assert adapter._reply_to_mode == "invalid"
def test_none_mode_defaults_to_first(self):
config = PlatformConfig(enabled=True, token="test-token")
adapter = TelegramAdapter(config)
assert adapter._reply_to_mode == "first"
def test_empty_string_mode_defaults_to_first(self):
config = PlatformConfig(enabled=True, token="test-token", reply_to_mode="")
adapter = TelegramAdapter(config)
assert adapter._reply_to_mode == "first"
class TestShouldThreadReply:
"""Tests for _should_thread_reply method."""
def test_no_reply_to_returns_false(self, adapter_factory):
adapter = adapter_factory(reply_to_mode="first")
assert adapter._should_thread_reply(None, 0) is False
assert adapter._should_thread_reply("", 0) is False
def test_off_mode_never_threads(self, adapter_factory):
adapter = adapter_factory(reply_to_mode="off")
assert adapter._should_thread_reply("msg-123", 0) is False
assert adapter._should_thread_reply("msg-123", 1) is False
assert adapter._should_thread_reply("msg-123", 5) is False
def test_first_mode_only_first_chunk(self, adapter_factory):
adapter = adapter_factory(reply_to_mode="first")
assert adapter._should_thread_reply("msg-123", 0) is True
assert adapter._should_thread_reply("msg-123", 1) is False
assert adapter._should_thread_reply("msg-123", 2) is False
assert adapter._should_thread_reply("msg-123", 10) is False
def test_all_mode_all_chunks(self, adapter_factory):
adapter = adapter_factory(reply_to_mode="all")
assert adapter._should_thread_reply("msg-123", 0) is True
assert adapter._should_thread_reply("msg-123", 1) is True
assert adapter._should_thread_reply("msg-123", 2) is True
assert adapter._should_thread_reply("msg-123", 10) is True
def test_invalid_mode_falls_back_to_first(self, adapter_factory):
"""Invalid mode behaves like 'first' - only first chunk threads."""
adapter = adapter_factory(reply_to_mode="invalid")
assert adapter._should_thread_reply("msg-123", 0) is True
assert adapter._should_thread_reply("msg-123", 1) is False
class TestSendWithReplyToMode:
"""Tests for send() method respecting reply_to_mode."""
@pytest.mark.asyncio
async def test_off_mode_no_reply_threading(self, adapter_factory):
adapter = adapter_factory(reply_to_mode="off")
adapter._bot = MagicMock()
adapter._bot.send_message = AsyncMock(return_value=MagicMock(message_id=1))
adapter.truncate_message = lambda content, max_len: ["chunk1", "chunk2", "chunk3"]
await adapter.send("12345", "test content", reply_to="999")
for call in adapter._bot.send_message.call_args_list:
assert call.kwargs.get("reply_to_message_id") is None
@pytest.mark.asyncio
async def test_first_mode_only_first_chunk_threads(self, adapter_factory):
adapter = adapter_factory(reply_to_mode="first")
adapter._bot = MagicMock()
adapter._bot.send_message = AsyncMock(return_value=MagicMock(message_id=1))
adapter.truncate_message = lambda content, max_len: ["chunk1", "chunk2", "chunk3"]
await adapter.send("12345", "test content", reply_to="999")
calls = adapter._bot.send_message.call_args_list
assert len(calls) == 3
assert calls[0].kwargs.get("reply_to_message_id") == 999
assert calls[1].kwargs.get("reply_to_message_id") is None
assert calls[2].kwargs.get("reply_to_message_id") is None
@pytest.mark.asyncio
async def test_all_mode_all_chunks_thread(self, adapter_factory):
adapter = adapter_factory(reply_to_mode="all")
adapter._bot = MagicMock()
adapter._bot.send_message = AsyncMock(return_value=MagicMock(message_id=1))
adapter.truncate_message = lambda content, max_len: ["chunk1", "chunk2", "chunk3"]
await adapter.send("12345", "test content", reply_to="999")
calls = adapter._bot.send_message.call_args_list
assert len(calls) == 3
for call in calls:
assert call.kwargs.get("reply_to_message_id") == 999
@pytest.mark.asyncio
async def test_no_reply_to_param_no_threading(self, adapter_factory):
adapter = adapter_factory(reply_to_mode="all")
adapter._bot = MagicMock()
adapter._bot.send_message = AsyncMock(return_value=MagicMock(message_id=1))
adapter.truncate_message = lambda content, max_len: ["chunk1", "chunk2"]
await adapter.send("12345", "test content", reply_to=None)
calls = adapter._bot.send_message.call_args_list
for call in calls:
assert call.kwargs.get("reply_to_message_id") is None
@pytest.mark.asyncio
async def test_single_chunk_respects_mode(self, adapter_factory):
adapter = adapter_factory(reply_to_mode="first")
adapter._bot = MagicMock()
adapter._bot.send_message = AsyncMock(return_value=MagicMock(message_id=1))
adapter.truncate_message = lambda content, max_len: ["single chunk"]
await adapter.send("12345", "test", reply_to="999")
calls = adapter._bot.send_message.call_args_list
assert len(calls) == 1
assert calls[0].kwargs.get("reply_to_message_id") == 999
class TestConfigSerialization:
"""Tests for reply_to_mode serialization."""
def test_to_dict_includes_reply_to_mode(self):
config = PlatformConfig(enabled=True, token="test", reply_to_mode="all")
result = config.to_dict()
assert result["reply_to_mode"] == "all"
def test_from_dict_loads_reply_to_mode(self):
data = {"enabled": True, "token": "test", "reply_to_mode": "off"}
config = PlatformConfig.from_dict(data)
assert config.reply_to_mode == "off"
def test_from_dict_defaults_to_first(self):
data = {"enabled": True, "token": "test"}
config = PlatformConfig.from_dict(data)
assert config.reply_to_mode == "first"
class TestEnvVarOverride:
"""Tests for TELEGRAM_REPLY_TO_MODE environment variable override."""
def _make_config(self):
config = GatewayConfig()
config.platforms[Platform.TELEGRAM] = PlatformConfig(enabled=True, token="test")
return config
def test_env_var_sets_off_mode(self):
config = self._make_config()
with patch.dict(os.environ, {"TELEGRAM_REPLY_TO_MODE": "off"}, clear=False):
_apply_env_overrides(config)
assert config.platforms[Platform.TELEGRAM].reply_to_mode == "off"
def test_env_var_sets_all_mode(self):
config = self._make_config()
with patch.dict(os.environ, {"TELEGRAM_REPLY_TO_MODE": "all"}, clear=False):
_apply_env_overrides(config)
assert config.platforms[Platform.TELEGRAM].reply_to_mode == "all"
def test_env_var_case_insensitive(self):
config = self._make_config()
with patch.dict(os.environ, {"TELEGRAM_REPLY_TO_MODE": "ALL"}, clear=False):
_apply_env_overrides(config)
assert config.platforms[Platform.TELEGRAM].reply_to_mode == "all"
def test_env_var_invalid_value_ignored(self):
config = self._make_config()
with patch.dict(os.environ, {"TELEGRAM_REPLY_TO_MODE": "banana"}, clear=False):
_apply_env_overrides(config)
assert config.platforms[Platform.TELEGRAM].reply_to_mode == "first"
def test_env_var_empty_value_ignored(self):
config = self._make_config()
with patch.dict(os.environ, {"TELEGRAM_REPLY_TO_MODE": ""}, clear=False):
_apply_env_overrides(config)
assert config.platforms[Platform.TELEGRAM].reply_to_mode == "first"

View File

@@ -665,11 +665,19 @@ def test_skill_installs_cleanly_under_skills_guard():
source="official/migration/openclaw-migration",
)
# The migration script legitimately references AGENTS.md (migrating
# workspace instructions), which triggers a false-positive
# agent_config_mod finding. Accept "caution" or "safe" — just not
# "dangerous" from a *real* threat.
# The migration script has several known false-positive findings from the
# security scanner. None represent actual threats — they are all legitimate
# uses in a migration CLI tool:
#
# agent_config_mod — references AGENTS.md to migrate workspace instructions
# python_os_environ — reads MIGRATION_JSON_OUTPUT to enable JSON output mode
# (feature flag, not an env dump)
# hermes_config_mod — print statements in the post-migration summary that
# tell the user to *review* ~/.hermes/config.yaml;
# the script never writes to that file
#
# Accept "caution" or "safe" — just not "dangerous" from a *real* threat.
assert result.verdict in ("safe", "caution", "dangerous"), f"Unexpected verdict: {result.verdict}"
# All findings should be the known false-positive for AGENTS.md
KNOWN_FALSE_POSITIVES = {"agent_config_mod", "python_os_environ", "hermes_config_mod"}
for f in result.findings:
assert f.pattern_id == "agent_config_mod", f"Unexpected finding: {f}"
assert f.pattern_id in KNOWN_FALSE_POSITIVES, f"Unexpected finding: {f}"

View File

@@ -4,6 +4,7 @@ from pathlib import Path
from unittest.mock import patch
from tools.skills_sync import (
_get_bundled_dir,
_read_manifest,
_write_manifest,
_discover_bundled_skills,
@@ -467,3 +468,24 @@ class TestSyncSkills:
new_bundled_hash = _dir_hash(bundled / "old-skill")
assert manifest["old-skill"] == new_bundled_hash
assert manifest["old-skill"] != old_hash
class TestGetBundledDir:
def test_env_var_override(self, tmp_path, monkeypatch):
"""HERMES_BUNDLED_SKILLS env var overrides the default path resolution."""
custom_dir = tmp_path / "custom_skills"
custom_dir.mkdir()
monkeypatch.setenv("HERMES_BUNDLED_SKILLS", str(custom_dir))
assert _get_bundled_dir() == custom_dir
def test_default_without_env_var(self, monkeypatch):
"""Without the env var, falls back to relative path from __file__."""
monkeypatch.delenv("HERMES_BUNDLED_SKILLS", raising=False)
result = _get_bundled_dir()
assert result.name == "skills"
def test_env_var_empty_string_ignored(self, monkeypatch):
"""Empty HERMES_BUNDLED_SKILLS should fall back to default."""
monkeypatch.setenv("HERMES_BUNDLED_SKILLS", "")
result = _get_bundled_dir()
assert result.name == "skills"

View File

@@ -37,7 +37,14 @@ MANIFEST_FILE = SKILLS_DIR / ".bundled_manifest"
def _get_bundled_dir() -> Path:
"""Locate the bundled skills/ directory in the repo."""
"""Locate the bundled skills/ directory.
Checks HERMES_BUNDLED_SKILLS env var first (set by Nix wrapper),
then falls back to the relative path from this source file.
"""
env_override = os.getenv("HERMES_BUNDLED_SKILLS")
if env_override:
return Path(env_override)
return Path(__file__).parent.parent / "skills"

View File

@@ -59,6 +59,10 @@ The only prerequisite is **Git**. The installer automatically handles everything
You do **not** need to install Python, Node.js, ripgrep, or ffmpeg manually. The installer detects what's missing and installs it for you. Just make sure `git` is available (`git --version`).
:::
:::tip Nix users
If you use Nix (on NixOS, macOS, or Linux), there's a dedicated setup path with a Nix flake, declarative NixOS module, and optional container mode. See the **[Nix & NixOS Setup](./nix-setup.md)** guide.
:::
---
## Manual Installation

View File

@@ -0,0 +1,822 @@
---
sidebar_position: 3
title: "Nix & NixOS Setup"
description: "Install and deploy Hermes Agent with Nix — from quick `nix run` to fully declarative NixOS module with container mode"
---
# Nix & NixOS Setup
Hermes Agent ships a Nix flake with three levels of integration:
| Level | Who it's for | What you get |
|-------|-------------|--------------|
| **`nix run` / `nix profile install`** | Any Nix user (macOS, Linux) | Pre-built binary with all deps — then use the standard CLI workflow |
| **NixOS module (native)** | NixOS server deployments | Declarative config, hardened systemd service, managed secrets |
| **NixOS module (container)** | Agents that need self-modification | Everything above, plus a persistent Ubuntu container where the agent can `apt`/`pip`/`npm install` |
:::info What's different from the standard install
The `curl | bash` installer manages Python, Node, and dependencies itself. The Nix flake replaces all of that — every Python dependency is a Nix derivation built by [uv2nix](https://github.com/pyproject-nix/uv2nix), and runtime tools (Node.js, git, ripgrep, ffmpeg) are wrapped into the binary's PATH. There is no runtime pip, no venv activation, no `npm install`.
**For non-NixOS users**, this only changes the install step. Everything after (`hermes setup`, `hermes gateway install`, config editing) works identically to the standard install.
**For NixOS module users**, the entire lifecycle is different: configuration lives in `configuration.nix`, secrets go through sops-nix/agenix, the service is a systemd unit, and CLI config commands are blocked. You manage hermes the same way you manage any other NixOS service.
:::
## Prerequisites
- **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 (Any Nix User)
No clone needed. Nix fetches, builds, and runs everything:
```bash
# Run directly (builds on first use, cached after)
nix run github:NousResearch/hermes-agent -- setup
nix run github:NousResearch/hermes-agent -- chat
# Or install persistently
nix profile install github:NousResearch/hermes-agent
hermes setup
hermes chat
```
After `nix profile install`, `hermes`, `hermes-agent`, and `hermes-acp` are on your PATH. From here, the workflow is identical to the [standard installation](./installation.md) — `hermes setup` walks you through provider selection, `hermes gateway install` sets up a launchd (macOS) or systemd user service, and config lives in `~/.hermes/`.
<details>
<summary><strong>Building from a local clone</strong></summary>
```bash
git clone https://github.com/NousResearch/hermes-agent.git
cd hermes-agent
nix build
./result/bin/hermes setup
```
</details>
---
## NixOS Module
The flake exports `nixosModules.default` — a full NixOS service module that declaratively manages user creation, directories, config generation, secrets, documents, and service lifecycle.
:::note
This module requires NixOS. For non-NixOS systems (macOS, other Linux distros), use `nix profile install` and the standard CLI workflow above.
:::
### Add the Flake Input
```nix
# /etc/nixos/flake.nix (or your system flake)
{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11";
hermes-agent.url = "github:NousResearch/hermes-agent";
};
outputs = { nixpkgs, hermes-agent, ... }: {
nixosConfigurations.your-host = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules = [
hermes-agent.nixosModules.default
./configuration.nix
];
};
};
}
```
### Minimal Configuration
```nix
# configuration.nix
{ config, ... }: {
services.hermes-agent = {
enable = true;
settings.model.default = "anthropic/claude-sonnet-4";
environmentFiles = [ config.sops.secrets."hermes-env".path ];
addToSystemPackages = true;
};
}
```
That's it. `nixos-rebuild switch` creates the `hermes` user, generates `config.yaml`, wires up secrets, and starts the gateway — a long-running service that connects the agent to messaging platforms (Telegram, Discord, etc.) and listens for incoming messages.
:::warning Secrets are required
The `environmentFiles` line above assumes you have [sops-nix](https://github.com/Mic92/sops-nix) or [agenix](https://github.com/ryantm/agenix) configured. The file should contain at least one LLM provider key (e.g., `OPENROUTER_API_KEY=sk-or-...`). See [Secrets Management](#secrets-management) for full setup. If you don't have a secrets manager yet, you can use a plain file as a starting point — just ensure it's not world-readable:
```bash
echo "OPENROUTER_API_KEY=sk-or-your-key" | sudo install -m 0600 -o hermes /dev/stdin /var/lib/hermes/env
```
```nix
services.hermes-agent.environmentFiles = [ "/var/lib/hermes/env" ];
```
:::
:::tip addToSystemPackages
Setting `addToSystemPackages = true` does two things: puts the `hermes` CLI on your system PATH **and** sets `HERMES_HOME` system-wide so the interactive CLI shares state (sessions, skills, cron) with the gateway service. Without it, running `hermes` in your shell creates a separate `~/.hermes/` directory.
:::
### Verify It Works
After `nixos-rebuild switch`, check that the service is running:
```bash
# Check service status
systemctl status hermes-agent
# Watch logs (Ctrl+C to stop)
journalctl -u hermes-agent -f
# If addToSystemPackages is true, test the CLI
hermes version
hermes config # shows the generated config
```
### Choosing a Deployment Mode
The module supports two modes, controlled by `container.enable`:
| | **Native** (default) | **Container** |
|---|---|---|
| How it runs | Hardened systemd service on the host | Persistent Ubuntu container with `/nix/store` bind-mounted |
| Security | `NoNewPrivileges`, `ProtectSystem=strict`, `PrivateTmp` | Container isolation, runs as unprivileged user inside |
| Agent can self-install packages | No — only tools on the Nix-provided PATH | Yes — `apt`, `pip`, `npm` installs persist across restarts |
| Config surface | Same | Same |
| When to choose | Standard deployments, maximum security, reproducibility | Agent needs runtime package installation, mutable environment, experimental tools |
To enable container mode, add one line:
```nix
{
services.hermes-agent = {
enable = true;
container.enable = true;
# ... rest of config is identical
};
}
```
:::info
Container mode auto-enables `virtualisation.docker.enable` via `mkDefault`. If you use Podman instead, set `container.backend = "podman"` and `virtualisation.docker.enable = false`.
:::
---
## Configuration
### Declarative Settings
The `settings` option accepts an arbitrary attrset that is rendered as `config.yaml`. It supports deep merging across multiple module definitions (via `lib.recursiveUpdate`), so you can split config across files:
```nix
# base.nix
services.hermes-agent.settings = {
model.default = "anthropic/claude-sonnet-4";
toolsets = [ "all" ];
terminal = { backend = "local"; timeout = 180; };
};
# personality.nix
services.hermes-agent.settings = {
display = { compact = false; personality = "kawaii"; };
memory = { memory_enabled = true; user_profile_enabled = true; };
};
```
Both are deep-merged at evaluation time. Nix-declared keys always win over keys in an existing `config.yaml` on disk, but **user-added keys that Nix doesn't touch are preserved**. This means if the agent or a manual edit adds keys like `skills.disabled` or `streaming.enabled`, they survive `nixos-rebuild switch`.
:::note Model naming
`settings.model.default` uses the model identifier your provider expects. With [OpenRouter](https://openrouter.ai) (the default), these look like `"anthropic/claude-sonnet-4"` or `"google/gemini-3-flash"`. If you're using a provider directly (Anthropic, OpenAI), set `settings.model.base_url` to point at their API and use their native model IDs (e.g., `"claude-sonnet-4-20250514"`). When no `base_url` is set, Hermes defaults to OpenRouter.
:::
:::tip Discovering available config keys
The full set of config keys is defined in [`nix/config-keys.json`](https://github.com/NousResearch/hermes-agent/blob/main/nix/config-keys.json) (127 leaf keys). You can paste your existing `config.yaml` into the `settings` attrset — the structure maps 1:1. The build-time `config-drift` check catches any drift between the reference and the Python source.
:::
<details>
<summary><strong>Full example: all commonly customized settings</strong></summary>
```nix
{ config, ... }: {
services.hermes-agent = {
enable = true;
container.enable = true;
# ── 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 ────────────────────────────────────────────────────────
environmentFiles = [ config.sops.secrets."hermes-env".path ];
# ── Documents ──────────────────────────────────────────────────────
documents = {
"SOUL.md" = builtins.readFile /home/user/.hermes/SOUL.md;
"USER.md" = ./documents/USER.md;
};
# ── MCP Servers ────────────────────────────────────────────────────
mcpServers.filesystem = {
command = "npx";
args = [ "-y" "@modelcontextprotocol/server-filesystem" "/data/workspace" ];
};
# ── Container options ──────────────────────────────────────────────
container = {
image = "ubuntu:24.04";
backend = "docker";
extraVolumes = [ "/home/user/projects:/projects:rw" ];
extraOptions = [ "--gpus" "all" ];
};
# ── Service tuning ─────────────────────────────────────────────────
addToSystemPackages = true;
extraArgs = [ "--verbose" ];
restart = "always";
restartSec = 5;
};
}
```
</details>
### Escape Hatch: Bring Your Own Config
If you'd rather manage `config.yaml` entirely outside Nix, use `configFile`:
```nix
services.hermes-agent.configFile = /etc/hermes/config.yaml;
```
This bypasses `settings` entirely — no merge, no generation. The file is copied as-is to `$HERMES_HOME/config.yaml` on each activation.
### Customization Cheatsheet
Quick reference for the most common things Nix users want to customize:
| I want to... | Option | Example |
|---|---|---|
| Change the LLM model | `settings.model.default` | `"anthropic/claude-sonnet-4"` |
| Use a different provider endpoint | `settings.model.base_url` | `"https://openrouter.ai/api/v1"` |
| Add API keys | `environmentFiles` | `[ config.sops.secrets."hermes-env".path ]` |
| Give the agent a personality | `documents."SOUL.md"` | `builtins.readFile ./my-soul.md` |
| Add MCP tool servers | `mcpServers.<name>` | See [MCP Servers](#mcp-servers) |
| Mount host directories into container | `container.extraVolumes` | `[ "/data:/data:rw" ]` |
| Pass GPU access to container | `container.extraOptions` | `[ "--gpus" "all" ]` |
| Use Podman instead of Docker | `container.backend` | `"podman"` |
| Add tools to the service PATH (native only) | `extraPackages` | `[ pkgs.pandoc pkgs.imagemagick ]` |
| Use a custom base image | `container.image` | `"ubuntu:24.04"` |
| Override the hermes package | `package` | `inputs.hermes-agent.packages.${system}.default.override { ... }` |
| Change state directory | `stateDir` | `"/opt/hermes"` |
| Set the agent's working directory | `workingDirectory` | `"/home/user/projects"` |
---
## Secrets Management
:::danger Never put API keys in `settings` or `environment`
Values in Nix expressions end up in `/nix/store`, which is world-readable. Always use `environmentFiles` with a secrets manager.
:::
Both `environment` (non-secret vars) and `environmentFiles` (secret files) are merged into `$HERMES_HOME/.env` at activation time (`nixos-rebuild switch`). Hermes reads this file on every startup, so changes take effect with a `systemctl restart hermes-agent` — no container recreation needed.
### 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
];
}
```
The secrets file contains key-value pairs:
```yaml
# secrets/hermes.yaml (encrypted with sops)
hermes-env: |
OPENROUTER_API_KEY=sk-or-...
TELEGRAM_BOT_TOKEN=123456:ABC...
ANTHROPIC_API_KEY=sk-ant-...
```
### agenix
```nix
{
age.secrets.hermes-env.file = ./secrets/hermes-env.age;
services.hermes-agent.environmentFiles = [
config.age.secrets.hermes-env.path
];
}
```
### OAuth / Auth Seeding
For platforms requiring OAuth (e.g., Discord), use `authFile` to seed credentials on first deploy:
```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 to the state directory and preserved across rebuilds.
---
## Documents
The `documents` option installs files into the agent's working directory (the `workingDirectory`, which the agent reads as its workspace). Hermes looks for specific filenames by convention:
- **`SOUL.md`** — the agent's system prompt / personality. Hermes reads this on startup and uses it as persistent instructions that shape its behavior across all conversations.
- **`USER.md`** — context about the user the agent is interacting with.
- Any other files you place here are visible to the agent as workspace files.
```nix
{
services.hermes-agent.documents = {
"SOUL.md" = ''
You are a helpful research assistant specializing in NixOS packaging.
Always cite sources and prefer reproducible solutions.
'';
"USER.md" = ./documents/USER.md; # path reference, copied from Nix store
};
}
```
Values can be inline strings or path references. Files are installed on every `nixos-rebuild switch`.
---
## MCP Servers
The `mcpServers` option declaratively configures [MCP (Model Context Protocol)](https://modelcontextprotocol.io) servers. Each server uses either **stdio** (local command) or **HTTP** (remote URL) transport.
### Stdio Transport (Local Servers)
```nix
{
services.hermes-agent.mcpServers = {
filesystem = {
command = "npx";
args = [ "-y" "@modelcontextprotocol/server-filesystem" "/data/workspace" ];
};
github = {
command = "npx";
args = [ "-y" "@modelcontextprotocol/server-github" ];
env.GITHUB_PERSONAL_ACCESS_TOKEN = "\${GITHUB_TOKEN}"; # resolved from .env
};
};
}
```
:::tip
Environment variables in `env` values are resolved from `$HERMES_HOME/.env` at runtime. Use `environmentFiles` to inject secrets — never put tokens directly in Nix config.
:::
### HTTP Transport (Remote Servers)
```nix
{
services.hermes-agent.mcpServers.remote-api = {
url = "https://mcp.example.com/v1/mcp";
headers.Authorization = "Bearer \${MCP_REMOTE_API_KEY}";
timeout = 180;
};
}
```
### HTTP Transport with OAuth
Set `auth = "oauth"` for servers using OAuth 2.1. Hermes implements the full PKCE flow — metadata discovery, dynamic client registration, token exchange, and automatic refresh.
```nix
{
services.hermes-agent.mcpServers.my-oauth-server = {
url = "https://mcp.example.com/mcp";
auth = "oauth";
};
}
```
Tokens are stored in `$HERMES_HOME/mcp-tokens/<server-name>.json` and persist across restarts and rebuilds.
<details>
<summary><strong>Initial OAuth authorization on headless servers</strong></summary>
The first OAuth authorization requires a browser-based consent flow. In a headless deployment, Hermes prints the authorization URL to stdout/logs instead of opening a browser.
**Option A: Interactive bootstrap** — run the flow once via `docker exec` (container) or `sudo -u hermes` (native):
```bash
# Container mode
docker exec -it hermes-agent \
hermes mcp add my-oauth-server --url https://mcp.example.com/mcp --auth oauth
# Native mode
sudo -u hermes HERMES_HOME=/var/lib/hermes/.hermes \
hermes mcp add my-oauth-server --url https://mcp.example.com/mcp --auth oauth
```
The container uses `--network=host`, so the OAuth callback listener on `127.0.0.1` is reachable from the host browser.
**Option B: Pre-seed tokens** — complete the flow on a workstation, then copy tokens:
```bash
hermes mcp add my-oauth-server --url https://mcp.example.com/mcp --auth oauth
scp ~/.hermes/mcp-tokens/my-oauth-server{,.client}.json \
server:/var/lib/hermes/.hermes/mcp-tokens/
# Ensure: chown hermes:hermes, chmod 0600
```
</details>
### Sampling (Server-Initiated LLM Requests)
Some MCP servers can request LLM completions from the agent:
```nix
{
services.hermes-agent.mcpServers.analysis = {
command = "npx";
args = [ "-y" "analysis-server" ];
sampling = {
enabled = true;
model = "google/gemini-3-flash";
max_tokens_cap = 4096;
timeout = 30;
max_rpm = 10;
};
};
}
```
---
## Managed Mode
When hermes runs via the NixOS module, the following CLI commands are **blocked** with a descriptive error pointing you to `configuration.nix`:
| Blocked command | Why |
|---|---|
| `hermes setup` | Config is declarative — edit `settings` in your Nix config |
| `hermes config edit` | Config is generated from `settings` |
| `hermes config set <key> <value>` | Config is generated from `settings` |
| `hermes gateway install` | The systemd service is managed by NixOS |
| `hermes gateway uninstall` | The systemd service is managed by NixOS |
This prevents drift between what Nix declares and what's on disk. Detection uses two signals:
1. **`HERMES_MANAGED=true`** environment variable — set by the systemd service, visible to the gateway process
2. **`.managed` marker file** in `HERMES_HOME` — set by the activation script, visible to interactive shells (e.g., `docker exec -it hermes-agent hermes config set ...` is also blocked)
To change configuration, edit your Nix config and run `sudo nixos-rebuild switch`.
---
## Container Architecture
:::info
This section is only relevant if you're using `container.enable = true`. Skip it for native mode deployments.
:::
When container mode is enabled, hermes runs inside a persistent Ubuntu container with the Nix-built binary bind-mounted read-only 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)
│ ├── .env (merged from environment + environmentFiles)
│ ├── config.yaml (Nix-generated, deep-merged by activation)
│ ├── .managed (marker file)
│ ├── state.db, sessions/, memories/ (runtime state)
│ └── mcp-tokens/ (OAuth tokens for MCP servers)
├── home/ ──► /home/hermes (rw)
└── workspace/ (MESSAGING_CWD)
├── SOUL.md (from documents option)
└── (agent-created files)
Container writable layer (apt/pip/npm): /usr, /usr/local, /tmp
```
The Nix-built binary works inside the Ubuntu container because `/nix/store` is bind-mounted — it brings its own interpreter and all dependencies, so there's no reliance on the container's system libraries. The container entrypoint resolves through a `current-package` symlink: `/data/current-package/bin/hermes gateway run --replace`. On `nixos-rebuild switch`, only the symlink is updated — the container keeps running.
### What Persists Across What
| Event | Container recreated? | `/data` (state) | `/home/hermes` | Writable layer (`apt`/`pip`/`npm`) |
|---|---|---|---|---|
| `systemctl restart hermes-agent` | No | Persists | Persists | Persists |
| `nixos-rebuild switch` (code change) | No (symlink updated) | Persists | Persists | Persists |
| Host reboot | No | Persists | Persists | Persists |
| `nix-collect-garbage` | No (GC root) | Persists | Persists | Persists |
| Image change (`container.image`) | **Yes** | Persists | Persists | **Lost** |
| Volume/options change | **Yes** | Persists | Persists | **Lost** |
| `environment`/`environmentFiles` change | No | Persists | Persists | Persists |
The container is only recreated when its **identity hash** changes. The hash covers: schema version, image, `extraVolumes`, `extraOptions`, and the entrypoint script. Changes to environment variables, settings, documents, or the hermes package itself do **not** trigger recreation.
:::warning Writable layer loss
When the identity hash changes (image upgrade, new volumes, new container options), the container is destroyed and recreated from a fresh pull of `container.image`. Any `apt install`, `pip install`, or `npm install` packages in the writable layer are lost. State in `/data` and `/home/hermes` is preserved (these are bind mounts).
If the agent relies on specific packages, consider baking them into a custom image (`container.image = "my-registry/hermes-base:latest"`) or scripting their installation in the agent's SOUL.md.
:::
### GC Root Protection
The `preStart` script creates a GC root at `${stateDir}/.gc-root` pointing to the current hermes package. This prevents `nix-collect-garbage` from removing the running binary. If the GC root somehow breaks, restarting the service recreates it.
---
## Development
### Dev Shell
The flake provides a development shell with Python 3.11, uv, Node.js, and all runtime tools:
```bash
cd hermes-agent
nix develop
# Shell provides:
# - Python 3.11 + uv (deps installed into .venv on first entry)
# - Node.js 20, ripgrep, git, openssh, ffmpeg on PATH
# - Stamp-file optimization: re-entry is near-instant if deps haven't changed
hermes setup
hermes chat
```
### direnv (Recommended)
The included `.envrc` activates the dev shell automatically:
```bash
cd hermes-agent
direnv allow # one-time
# Subsequent entries are near-instant (stamp file skips dep install)
```
### Flake Checks
The flake includes build-time verification that runs in CI and locally:
```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.entry-points-sync # pyproject.toml ↔ Nix package sync
nix build .#checks.x86_64-linux.cli-commands # gateway/config subcommands
nix build .#checks.x86_64-linux.managed-guard # HERMES_MANAGED blocks mutation
nix build .#checks.x86_64-linux.bundled-skills # skills present in package
nix build .#checks.x86_64-linux.config-drift # config keys match Python source
nix build .#checks.x86_64-linux.config-roundtrip # merge script preserves user keys
```
<details>
<summary><strong>What each check verifies</strong></summary>
| Check | What it tests |
|---|---|
| `package-contents` | `hermes` and `hermes-agent` binaries exist and `hermes version` runs |
| `entry-points-sync` | Every `[project.scripts]` entry in `pyproject.toml` has a wrapped binary in the Nix package |
| `cli-commands` | `hermes --help` exposes `gateway` and `config` subcommands |
| `managed-guard` | `HERMES_MANAGED=true hermes config set ...` prints the NixOS error |
| `bundled-skills` | Skills directory exists, contains SKILL.md files, `HERMES_BUNDLED_SKILLS` is set in wrapper |
| `config-drift` | Leaf keys extracted from Python's `DEFAULT_CONFIG` match the committed `nix/config-keys.json` reference |
| `config-roundtrip` | 7 merge scenarios: fresh install, Nix override, user key preservation, mixed merge, MCP additive merge, nested deep merge, idempotency |
</details>
---
## Options Reference
### Core
| Option | Type | Default | Description |
|---|---|---|---|
| `enable` | `bool` | `false` | Enable the hermes-agent service |
| `package` | `package` | `hermes-agent` | The hermes-agent package to use |
| `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 and set `HERMES_HOME` system-wide |
### 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. Merged into `$HERMES_HOME/.env` at activation time |
| `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` on activation |
### 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` | `null` or `str` | `null` | Server command (stdio transport) |
| `mcpServers.<name>.args` | `listOf str` | `[]` | Command arguments |
| `mcpServers.<name>.env` | `attrsOf str` | `{}` | Environment variables for the server process |
| `mcpServers.<name>.url` | `null` or `str` | `null` | Server endpoint URL (HTTP/StreamableHTTP transport) |
| `mcpServers.<name>.headers` | `attrsOf str` | `{}` | HTTP headers, e.g. `Authorization` |
| `mcpServers.<name>.auth` | `null` or `"oauth"` | `null` | Authentication method. `"oauth"` enables OAuth 2.1 PKCE |
| `mcpServers.<name>.enabled` | `bool` | `true` | Enable or disable this server |
| `mcpServers.<name>.timeout` | `null` or `int` | `null` | Tool call timeout in seconds (default: 120) |
| `mcpServers.<name>.connect_timeout` | `null` or `int` | `null` | Connection timeout in seconds (default: 60) |
| `mcpServers.<name>.tools` | `null` or `submodule` | `null` | Tool filtering (`include`/`exclude` lists) |
| `mcpServers.<name>.sampling` | `null` or `submodule` | `null` | Sampling config for server-initiated LLM requests |
### 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=` value |
### Container
| Option | Type | Default | Description |
|---|---|---|---|
| `container.enable` | `bool` | `false` | Enable OCI container mode |
| `container.backend` | `enum ["docker" "podman"]` | `"docker"` | Container runtime |
| `container.image` | `str` | `"ubuntu:24.04"` | Base image (pulled at runtime) |
| `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 (deep-merged each rebuild)
│ ├── .managed # Marker: CLI config mutation blocked
│ ├── .env # Merged from environment + environmentFiles
│ ├── auth.json # OAuth credentials (seeded, then self-managed)
│ ├── gateway.pid
│ ├── state.db
│ ├── mcp-tokens/ # OAuth tokens for MCP servers
│ ├── sessions/
│ ├── memories/
│ ├── skills/
│ ├── cron/
│ └── logs/
├── home/ # Agent HOME
└── workspace/ # MESSAGING_CWD
├── SOUL.md # From documents option
└── (agent-created files)
```
### Container Mode
Same layout, mounted into the container:
| 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` | `${stateDir}/home` | `rw` | Persistent agent home — `pip install --user`, tool caches |
| `/usr`, `/usr/local`, `/tmp` | (writable layer) | `rw` | `apt`/`pip`/`npm` installs — persists across restarts, lost on recreation |
---
## Updating
```bash
# Update the flake input
nix flake update hermes-agent --flake /etc/nixos
# Rebuild
sudo nixos-rebuild switch
```
In container mode, the `current-package` symlink is updated and the agent picks up the new binary on restart. No container recreation, no loss of installed packages.
---
## Troubleshooting
:::tip Podman users
All `docker` commands below work the same with `podman`. Substitute accordingly if you set `container.backend = "podman"`.
:::
### Service Logs
```bash
# Both modes use the same systemd unit
journalctl -u hermes-agent -f
# Container mode: also available directly
docker logs -f hermes-agent
```
### Container Inspection
```bash
systemctl status hermes-agent
docker ps -a --filter name=hermes-agent
docker inspect hermes-agent --format='{{.State.Status}}'
docker exec -it hermes-agent bash
docker exec hermes-agent readlink /data/current-package
docker exec hermes-agent cat /data/.container-identity
```
### Force Container Recreation
If you need to reset the 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
```
### Verify Secrets Are Loaded
If the agent starts but can't authenticate with the LLM provider, check that the `.env` file was merged correctly:
```bash
# Native mode
sudo -u hermes cat /var/lib/hermes/.hermes/.env
# Container mode
docker exec hermes-agent cat /data/.hermes/.env
```
### GC Root Verification
```bash
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 | `extraVolumes`, `extraOptions`, or `image` changed | Expected — writable layer resets. Reinstall packages or use a custom image |
| `hermes version` shows old version | 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 | Restart the service (preStart recreates the GC root) |

View File

@@ -9,6 +9,7 @@ const sidebars: SidebarsConfig = {
items: [
'getting-started/quickstart',
'getting-started/installation',
'getting-started/nix-setup',
'getting-started/updating',
'getting-started/learning-path',
],